From a362add8f0d9f8b7bf790d5d68c84bfd786c0b8b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 19 Aug 2026 13:03:14 -0600 Subject: [PATCH 001/170] feat: updated gitignore to not include coyote memory or workspace config --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5ebe2421..e154748f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /.idea/ /.scannerwork/ /.act/ +/.sisyphus/ +/.coyote/ From 4db9b92146b3ad54ba4fc8f4d67699c0923ae80c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 10:52:05 -0600 Subject: [PATCH 002/170] refactor: move shared servarr types into servarr_models SystemStatus was declared three times, byte for byte identically, in radarr_models, sonarr_models, and lidarr_models. MetadataProfile and the Lidarr release download body were each declared in a single servarr module despite having nothing servarr specific about them: MetadataProfile is an id and name pair structurally identical to QualityProfile, which already lives in servarr_models, and a download body of a guid plus an indexer id is the generic form of that request. Move all three into servarr_models, preserving that file's alphabetical ordering, and drop the prefix from LidarrReleaseDownloadBody now that it is no longer owned by one servarr. RadarrReleaseDownloadBody and SonarrReleaseDownloadBody deliberately stay where they are. Radarr's carries a movie id and Sonarr's carries optional series, episode, and season fields, so the three types are not interchangeable and only the Lidarr shape is the common one. Behavior is unchanged. Every remaining edit is an import path update or the rename at a call site. --- src/cli/lidarr/lidarr_command_tests.rs | 8 ++--- src/cli/lidarr/mod.rs | 5 +-- .../library/album_details_handler.rs | 9 +++-- .../library/album_details_handler_tests.rs | 8 ++--- .../library/artist_details_handler.rs | 9 +++-- .../library/artist_details_handler_tests.rs | 10 +++--- src/models/lidarr_models.rs | 34 ++----------------- src/models/lidarr_models_tests.rs | 7 ++-- src/models/radarr_models.rs | 9 +---- src/models/radarr_models_tests.rs | 6 ++-- src/models/servarr_models.rs | 30 ++++++++++++++++ src/models/sonarr_models.rs | 9 +---- src/models/sonarr_models_tests.rs | 5 +-- .../library/lidarr_library_network_tests.rs | 6 ++-- src/network/lidarr_network/library/mod.rs | 8 ++--- .../lidarr_network_test_utils.rs | 5 +-- .../lidarr_network/lidarr_network_tests.rs | 6 ++-- src/network/lidarr_network/mod.rs | 10 +++--- .../system/lidarr_system_network_tests.rs | 4 +-- src/network/lidarr_network/system/mod.rs | 4 +-- src/network/radarr_network/system/mod.rs | 4 +-- .../system/radarr_system_network_tests.rs | 4 +-- src/network/sonarr_network/system/mod.rs | 4 +-- .../system/sonarr_system_network_tests.rs | 4 +-- 24 files changed, 101 insertions(+), 107 deletions(-) diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 87dc6546..21c19dbe 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -284,10 +284,8 @@ mod tests { use crate::cli::lidarr::manual_search_command_handler::LidarrManualSearchCommand; use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand; use crate::cli::lidarr::trigger_automatic_search_command_handler::LidarrTriggerAutomaticSearchCommand; - use crate::models::lidarr_models::{ - BlocklistItem, BlocklistResponse, LidarrReleaseDownloadBody, LidarrTaskName, - }; - use crate::models::servarr_models::IndexerSettings; + use crate::models::lidarr_models::{BlocklistItem, BlocklistResponse, LidarrTaskName}; + use crate::models::servarr_models::{IndexerSettings, ReleaseDownloadBody}; use crate::{ app::App, cli::{ @@ -583,7 +581,7 @@ mod tests { #[tokio::test] async fn test_download_release_command() { - let expected_release_download_body = LidarrReleaseDownloadBody { + let expected_release_download_body = ReleaseDownloadBody { guid: "guid".to_owned(), indexer_id: 1, }; diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index 7ef4c255..6a3a6643 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -18,7 +18,8 @@ use super::{CliCommandHandler, Command}; use crate::cli::lidarr::manual_search_command_handler::{ LidarrManualSearchCommand, LidarrManualSearchCommandHandler, }; -use crate::models::lidarr_models::{LidarrReleaseDownloadBody, LidarrTaskName}; +use crate::models::lidarr_models::LidarrTaskName; +use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::{app::App, network::NetworkTrait}; @@ -231,7 +232,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' serde_json::to_string_pretty(&resp)? } LidarrCommand::DownloadRelease { guid, indexer_id } => { - let params = LidarrReleaseDownloadBody { guid, indexer_id }; + let params = ReleaseDownloadBody { guid, indexer_id }; let resp = self .network .handle_network_event(LidarrEvent::DownloadRelease(params).into()) diff --git a/src/handlers/lidarr_handlers/library/album_details_handler.rs b/src/handlers/lidarr_handlers/library/album_details_handler.rs index 4e69596a..4645df6c 100644 --- a/src/handlers/lidarr_handlers/library/album_details_handler.rs +++ b/src/handlers/lidarr_handlers/library/album_details_handler.rs @@ -5,10 +5,9 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; use crate::models::Route; -use crate::models::lidarr_models::{ - LidarrHistoryItem, LidarrRelease, LidarrReleaseDownloadBody, Track, -}; +use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease, Track}; use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock}; +use crate::models::servarr_models::ReleaseDownloadBody; use crate::models::stateful_table::SortOption; use crate::network::lidarr_network::LidarrEvent; use serde_json::Number; @@ -281,7 +280,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AlbumDetailsHandler< .unwrap() .album_releases .current_selection(); - let params = LidarrReleaseDownloadBody { + let params = ReleaseDownloadBody { guid: guid.clone(), indexer_id: *indexer_id, }; @@ -386,7 +385,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AlbumDetailsHandler< .unwrap() .album_releases .current_selection(); - let params = LidarrReleaseDownloadBody { + let params = ReleaseDownloadBody { guid: guid.clone(), indexer_id: *indexer_id, }; diff --git a/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs index 843b5227..f6335f09 100644 --- a/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs @@ -9,10 +9,10 @@ mod tests { AlbumDetailsHandler, releases_sorting_options, }; use crate::models::HorizontallyScrollableText; - use crate::models::lidarr_models::{LidarrRelease, LidarrReleaseDownloadBody}; + use crate::models::lidarr_models::LidarrRelease; use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock}; use crate::models::servarr_data::lidarr::modals::AlbumDetailsModal; - use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::models::servarr_models::{Quality, QualityWrapper, ReleaseDownloadBody}; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::Number; @@ -297,7 +297,7 @@ mod tests { assert_navigation_popped!(app, ActiveLidarrBlock::ManualAlbumSearch.into()); assert_some_eq_x!( &app.data.lidarr_data.prompt_confirm_action, - &LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody { + &LidarrEvent::DownloadRelease(ReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, }) @@ -654,7 +654,7 @@ mod tests { assert_navigation_popped!(app, ActiveLidarrBlock::ManualAlbumSearch.into()); assert_some_eq_x!( &app.data.lidarr_data.prompt_confirm_action, - &LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody { + &LidarrEvent::DownloadRelease(ReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, }) diff --git a/src/handlers/lidarr_handlers/library/artist_details_handler.rs b/src/handlers/lidarr_handlers/library/artist_details_handler.rs index fa5fd77f..0b5908eb 100644 --- a/src/handlers/lidarr_handlers/library/artist_details_handler.rs +++ b/src/handlers/lidarr_handlers/library/artist_details_handler.rs @@ -4,13 +4,12 @@ use crate::handlers::lidarr_handlers::history::history_sorting_options; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; -use crate::models::lidarr_models::{ - Album, LidarrHistoryItem, LidarrRelease, LidarrReleaseDownloadBody, -}; +use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_SELECTION_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, }; +use crate::models::servarr_models::ReleaseDownloadBody; use crate::models::stateful_table::SortOption; use crate::models::{BlockSelectionState, Route}; use crate::network::lidarr_network::LidarrEvent; @@ -202,7 +201,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler .discography_releases .current_selection() .clone(); - let params = LidarrReleaseDownloadBody { guid, indexer_id }; + let params = ReleaseDownloadBody { guid, indexer_id }; self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::DownloadRelease(params)); } @@ -365,7 +364,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler .discography_releases .current_selection() .clone(); - let params = LidarrReleaseDownloadBody { guid, indexer_id }; + let params = ReleaseDownloadBody { guid, indexer_id }; self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::DownloadRelease(params)); diff --git a/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs index 2e6e15e5..1a0aa5e5 100644 --- a/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs @@ -137,8 +137,9 @@ mod tests { use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler; - use crate::models::lidarr_models::{LidarrHistoryItem, LidarrReleaseDownloadBody}; + use crate::models::lidarr_models::LidarrHistoryItem; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ artist, torrent_release, @@ -307,7 +308,7 @@ mod tests { assert_navigation_popped!(app, ActiveLidarrBlock::ManualArtistSearch.into()); assert_eq!( app.data.lidarr_data.prompt_confirm_action, - Some(LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody { + Some(LidarrEvent::DownloadRelease(ReleaseDownloadBody { guid: release.guid, indexer_id: release.indexer_id, })) @@ -449,10 +450,11 @@ mod tests { use crate::assert_navigation_pushed; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler; - use crate::models::lidarr_models::{Artist, LidarrReleaseDownloadBody}; + use crate::models::lidarr_models::Artist; use crate::models::servarr_data::lidarr::lidarr_data::{ ActiveLidarrBlock, EDIT_ARTIST_SELECTION_BLOCKS, }; + use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::torrent_release; use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; @@ -801,7 +803,7 @@ mod tests { assert_navigation_popped!(app, ActiveLidarrBlock::ManualArtistSearch.into()); assert_eq!( app.data.lidarr_data.prompt_confirm_action, - Some(LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody { + Some(LidarrEvent::DownloadRelease(ReleaseDownloadBody { guid: release.guid, indexer_id: release.indexer_id, })) diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 72cdd3c8..333d51d9 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -1,8 +1,8 @@ use super::{ HorizontallyScrollableText, Serdeable, servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerTestResult, QualityProfile, QualityWrapper, RootFolder, - SecurityConfig, Tag, + DiskSpace, HostConfig, Indexer, IndexerTestResult, MetadataProfile, QualityProfile, + QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag, }, }; use crate::models::servarr_models::{IndexerSettings, LogResponse, QueueEvent, Update}; @@ -97,22 +97,6 @@ pub struct ArtistStatistics { impl Eq for ArtistStatistics {} -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct MetadataProfile { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: String, -} - -impl From<(&i64, &String)> for MetadataProfile { - fn from(value: (&i64, &String)) -> Self { - MetadataProfile { - id: *value.0, - name: value.1.clone(), - } - } -} - #[derive( Serialize, Deserialize, @@ -230,13 +214,6 @@ pub struct DownloadsResponse { pub records: Vec, } -#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SystemStatus { - pub version: String, - pub start_time: DateTime, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddArtistSearchResult { @@ -492,13 +469,6 @@ pub struct LidarrRelease { pub quality: QualityWrapper, } -#[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] -#[serde(rename_all = "camelCase")] -pub struct LidarrReleaseDownloadBody { - pub guid: String, - pub indexer_id: i64, -} - #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BlocklistItem { diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 7a31e9a4..f3fd6ad4 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -7,12 +7,13 @@ mod tests { use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, - LidarrHistoryWrapper, LidarrRelease, LidarrTask, MediaInfo, Member, MetadataProfile, - MonitorType, NewItemMonitorType, SystemStatus, Track, TrackFile, + LidarrHistoryWrapper, LidarrRelease, LidarrTask, MediaInfo, Member, MonitorType, + NewItemMonitorType, Track, TrackFile, }; use crate::models::servarr_models::{ DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, - QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, + MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, + Update, }; use crate::models::{ Serdeable, diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index baf1c627..8b59fd3d 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -13,7 +13,7 @@ use strum_macros::{Display, EnumIter}; use super::Serdeable; use super::servarr_models::{ DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, RootFolder, SecurityConfig, Tag, Update, + QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update, }; #[cfg(test)] @@ -401,13 +401,6 @@ pub struct RadarrReleaseDownloadBody { pub movie_id: i64, } -#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SystemStatus { - pub version: String, - pub start_time: DateTime, -} - #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct RadarrHistoryWrapper { diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index d7843dbe..b9ed2485 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -12,9 +12,11 @@ mod tests { AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease, - RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus, Tag, Update, + RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update, + }, + servarr_models::{ + HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, SystemStatus, }, - servarr_models::{HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig}, }; #[test] diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 36a00330..71c74fb9 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -238,6 +238,22 @@ pub struct LogResponse { pub records: Vec, } +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct MetadataProfile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: String, +} + +impl From<(&i64, &String)> for MetadataProfile { + fn from(value: (&i64, &String)) -> Self { + MetadataProfile { + id: *value.0, + name: value.1.clone(), + } + } +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Quality { pub name: String, @@ -277,6 +293,13 @@ pub struct QueueEvent { pub duration: Option, } +#[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseDownloadBody { + pub guid: String, + pub indexer_id: i64, +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RootFolder { @@ -303,6 +326,13 @@ pub struct SecurityConfig { pub certificate_validation: CertificateValidation, } +#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SystemStatus { + pub version: String, + pub start_time: DateTime, +} + #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct Tag { #[serde(deserialize_with = "super::from_i64")] diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 3de2d111..1b139543 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -17,7 +17,7 @@ use super::{ HorizontallyScrollableText, Serdeable, servarr_models::{ DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, RootFolder, SecurityConfig, Tag, Update, + QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update, }, }; @@ -626,10 +626,3 @@ serde_enum_from!( Value(Value), } ); - -#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SystemStatus { - pub version: String, - pub start_time: DateTime, -} diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 205e2e7b..d7ea2026 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -7,13 +7,14 @@ mod tests { Serdeable, servarr_models::{ DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Language, Log, - LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, + LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, + Update, }, sonarr_models::{ AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse, Episode, EpisodeFile, Series, SeriesMonitor, SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, - SonarrTaskName, SystemStatus, + SonarrTaskName, }, }; 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 333d8a6a..d31f8c32 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { - use crate::models::lidarr_models::LidarrReleaseDownloadBody; use crate::models::servarr_data::Notification; + use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use pretty_assertions::assert_eq; @@ -9,7 +9,7 @@ mod tests { #[tokio::test] async fn test_handle_download_lidarr_release_event_uses_provided_params() { - let params = LidarrReleaseDownloadBody { + let params = ReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, }; @@ -44,7 +44,7 @@ mod tests { #[tokio::test] async fn test_handle_download_lidarr_release_event_sets_failure_notification_on_error() { - let params = LidarrReleaseDownloadBody { + let params = ReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, }; diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index aa68d60e..29c89a8e 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -1,5 +1,5 @@ -use crate::models::lidarr_models::LidarrReleaseDownloadBody; use crate::models::servarr_data::Notification; +use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; @@ -17,9 +17,9 @@ mod lidarr_library_network_tests; impl Network<'_, '_> { pub(in crate::network::lidarr_network) async fn download_lidarr_release( &mut self, - lidarr_release_download_body: LidarrReleaseDownloadBody, + lidarr_release_download_body: ReleaseDownloadBody, ) -> Result { - let event = LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody::default()); + let event = LidarrEvent::DownloadRelease(ReleaseDownloadBody::default()); info!("Downloading Lidarr release with params: {lidarr_release_download_body:?}"); let request_props = self @@ -33,7 +33,7 @@ impl Network<'_, '_> { .await; let result = self - .handle_request::(request_props, |_, mut app| { + .handle_request::(request_props, |_, mut app| { app.notification = Some(Notification::new( "Download Result".to_owned(), "Download request sent successfully".to_owned(), diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index e4b73cdf..716a4df1 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -6,11 +6,12 @@ pub mod test_utils { AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, - MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, Track, TrackFile, + NewItemMonitorType, Ratings, Track, TrackFile, }; use crate::models::servarr_models::IndexerSettings; use crate::models::servarr_models::{ - Indexer, IndexerField, Quality, QualityProfile, QualityWrapper, RootFolder, Tag, + Indexer, IndexerField, MetadataProfile, Quality, QualityProfile, QualityWrapper, RootFolder, + SystemStatus, Tag, }; use crate::models::{HorizontallyScrollableText, ScrollableText}; use bimap::BiMap; diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index 289ff9a1..bbb35abc 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -2,10 +2,12 @@ mod tests { use crate::app::App; use crate::models::lidarr_models::{ - AddArtistBody, DeleteParams, EditArtistParams, LidarrSerdeable, MetadataProfile, + AddArtistBody, DeleteParams, EditArtistParams, LidarrSerdeable, }; use crate::models::servarr_data::lidarr::modals::EditArtistModal; - use crate::models::servarr_models::{EditIndexerParams, IndexerSettings, QualityProfile, Tag}; + use crate::models::servarr_models::{ + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, Tag, + }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent}; use bimap::BiMap; diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index ef52a96e..ec816328 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -3,10 +3,12 @@ use log::info; use super::{NetworkEvent, NetworkResource}; use crate::models::lidarr_models::{ - AddArtistBody, AddLidarrRootFolderBody, DeleteParams, EditArtistParams, - LidarrReleaseDownloadBody, LidarrSerdeable, LidarrTaskName, MetadataProfile, + AddArtistBody, AddLidarrRootFolderBody, DeleteParams, EditArtistParams, LidarrSerdeable, + LidarrTaskName, +}; +use crate::models::servarr_models::{ + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, }; -use crate::models::servarr_models::{EditIndexerParams, IndexerSettings, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; mod blocklist; @@ -39,7 +41,7 @@ pub enum LidarrEvent { DeleteRootFolder(i64), DeleteTag(i64), DeleteTrackFile(i64), - DownloadRelease(LidarrReleaseDownloadBody), + DownloadRelease(ReleaseDownloadBody), EditArtist(EditArtistParams), EditAllIndexerSettings(IndexerSettings), EditIndexer(EditIndexerParams), diff --git a/src/network/lidarr_network/system/lidarr_system_network_tests.rs b/src/network/lidarr_network/system/lidarr_system_network_tests.rs index 596df7be..e6b1927b 100644 --- a/src/network/lidarr_network/system/lidarr_system_network_tests.rs +++ b/src/network/lidarr_network/system/lidarr_system_network_tests.rs @@ -1,9 +1,9 @@ #[cfg(test)] mod tests { use crate::models::HorizontallyScrollableText; - use crate::models::lidarr_models::{LidarrSerdeable, LidarrTask, LidarrTaskName, SystemStatus}; + use crate::models::lidarr_models::{LidarrSerdeable, LidarrTask, LidarrTaskName}; use crate::models::servarr_models::{ - DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::updates; diff --git a/src/network/lidarr_network/system/mod.rs b/src/network/lidarr_network/system/mod.rs index 8f50f317..46e1c3cc 100644 --- a/src/network/lidarr_network/system/mod.rs +++ b/src/network/lidarr_network/system/mod.rs @@ -1,6 +1,6 @@ -use crate::models::lidarr_models::{LidarrTask, LidarrTaskName, SystemStatus}; +use crate::models::lidarr_models::{LidarrTask, LidarrTaskName}; use crate::models::servarr_models::{ - CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::lidarr_network::LidarrEvent; diff --git a/src/network/radarr_network/system/mod.rs b/src/network/radarr_network/system/mod.rs index 40428cd3..a51371d1 100644 --- a/src/network/radarr_network/system/mod.rs +++ b/src/network/radarr_network/system/mod.rs @@ -1,6 +1,6 @@ -use crate::models::radarr_models::{RadarrTask, RadarrTaskName, SystemStatus}; +use crate::models::radarr_models::{RadarrTask, RadarrTaskName}; use crate::models::servarr_models::{ - CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::radarr_network::RadarrEvent; diff --git a/src/network/radarr_network/system/radarr_system_network_tests.rs b/src/network/radarr_network/system/radarr_system_network_tests.rs index 19f15b9b..ae1352b3 100644 --- a/src/network/radarr_network/system/radarr_system_network_tests.rs +++ b/src/network/radarr_network/system/radarr_system_network_tests.rs @@ -1,9 +1,9 @@ #[cfg(test)] mod tests { use crate::models::HorizontallyScrollableText; - use crate::models::radarr_models::{RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus}; + use crate::models::radarr_models::{RadarrSerdeable, RadarrTask, RadarrTaskName}; use crate::models::servarr_models::{ - DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::radarr_network::RadarrEvent; diff --git a/src/network/sonarr_network/system/mod.rs b/src/network/sonarr_network/system/mod.rs index c32b75b4..f9745b8d 100644 --- a/src/network/sonarr_network/system/mod.rs +++ b/src/network/sonarr_network/system/mod.rs @@ -1,7 +1,7 @@ use crate::models::servarr_models::{ - CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; -use crate::models::sonarr_models::{SonarrTask, SonarrTaskName, SystemStatus}; +use crate::models::sonarr_models::{SonarrTask, SonarrTaskName}; use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::sonarr_network::SonarrEvent; use crate::network::{Network, RequestMethod}; diff --git a/src/network/sonarr_network/system/sonarr_system_network_tests.rs b/src/network/sonarr_network/system/sonarr_system_network_tests.rs index 5742f224..1d0cf842 100644 --- a/src/network/sonarr_network/system/sonarr_system_network_tests.rs +++ b/src/network/sonarr_network/system/sonarr_system_network_tests.rs @@ -2,9 +2,9 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::servarr_models::{ - DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; - use crate::models::sonarr_models::{SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus}; + use crate::models::sonarr_models::{SonarrSerdeable, SonarrTask, SonarrTaskName}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::updates; From 29f043f5c71221c15051fb9675bc790b391f67e2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 15:28:49 -0600 Subject: [PATCH 003/170] feat: scaffold Readarr support across the state, app, handler, UI, and CLI layers Promote Route::Readarr from a unit stub to a tuple variant and wire Readarr through every layer, mirroring the Lidarr tree. Readarr appears as a fourth servarr tab and its library view renders the Authors table. Adds ReadarrData and its modals, the tick and context clue modules, the top-level ReadarrHandler, ReadarrUi with the Authors library view, and an empty ReadarrCommand behind ReadarrCliHandler. The default port is 8787. Promoting the route variant compiles cleanly without any of the arms that give it behaviour, so all six of those sites are wired explicitly here: App::on_tick, handlers::handle_events, handle_prompt_toggle, ServarrContextClueProvider, ui(), and verify_config_present_for_cli. Each was checked by removing the arm and confirming its covering assertion then fails. ReadarrEvent and ReadarrCommand are both declared empty. They gain variants alongside the network calls and CLI commands that use them, which keeps each later change to a single function. Adding the tab changes the rendered tab bar, so 13 existing snapshots regenerate. The only delta in each is that single tab bar line. This scaffold adds 24 dead_code warnings, all inside the Readarr tree and none outside it. They clear as the handlers, UI, network events, and CLI commands that consume these items land: block arrays awaiting their accepts implementations and BlockSelectionState seeding, modal and state fields awaiting their handlers, the CLI handler fields awaiting an inhabited command enum, and the top-level handler fields awaiting sub-handlers. The count must strictly decrease from here, so a later increase is a defect rather than transient noise. The 12 pre-existing collapsible_match warnings are unrelated and untouched. --- README.md | 10 +- src/app/app_tests.rs | 21 + src/app/context_clues.rs | 2 + src/app/context_clues_tests.rs | 11 + src/app/mod.rs | 58 +- src/app/readarr/mod.rs | 48 + src/app/readarr/readarr_context_clues.rs | 205 ++++ .../readarr/readarr_context_clues_tests.rs | 540 +++++++++ src/app/readarr/readarr_tests.rs | 112 ++ src/cli/mod.rs | 10 + src/cli/readarr/mod.rs | 45 + src/cli/readarr/readarr_command_tests.rs | 15 + src/handlers/handlers_tests.rs | 22 +- src/handlers/mod.rs | 8 + src/handlers/readarr_handlers/mod.rs | 74 ++ .../readarr_handlers/readarr_handler_tests.rs | 52 + src/logos.rs | 2 - src/main.rs | 2 +- src/models/mod.rs | 6 +- src/models/readarr_models.rs | 501 ++++++++ src/models/readarr_models_tests.rs | 1022 +++++++++++++++++ src/models/servarr_data/mod.rs | 1 + src/models/servarr_data/readarr/mod.rs | 2 + src/models/servarr_data/readarr/modals.rs | 271 +++++ .../servarr_data/readarr/modals_tests.rs | 322 ++++++ .../servarr_data/readarr/readarr_data.rs | 980 ++++++++++++++++ .../readarr/readarr_data_tests.rs | 789 +++++++++++++ src/network/mod.rs | 8 + src/network/network_tests.rs | 1 + src/network/readarr_network/mod.rs | 29 + src/ui/mod.rs | 6 + src/ui/readarr_ui/library/library_ui_tests.rs | 235 ++++ src/ui/readarr_ui/library/mod.rs | 176 +++ ...pshot_tests__library_ui_renders_empty.snap | 5 + ...hot_tests__library_ui_renders_loading.snap | 8 + ...apshot_tests__readarr_library_Authors.snap | 7 + ...ts__readarr_library_AuthorsSortPrompt.snap | 42 + ..._tests__readarr_library_FilterAuthors.snap | 28 + ...s__readarr_library_FilterAuthorsError.snap | 31 + ..._tests__readarr_library_SearchAuthors.snap | 28 + ...s__readarr_library_SearchAuthorsError.snap | 31 + src/ui/readarr_ui/mod.rs | 217 ++++ src/ui/readarr_ui/readarr_ui_tests.rs | 46 + ..._snapshot_tests__readarr_tabs_Authors.snap | 54 + ...napshot_tests__readarr_tabs_Blocklist.snap | 54 + ...napshot_tests__readarr_tabs_Downloads.snap | 54 + ..._snapshot_tests__readarr_tabs_History.snap | 54 + ...snapshot_tests__readarr_tabs_Indexers.snap | 54 + ...pshot_tests__readarr_tabs_RootFolders.snap | 54 + ...__snapshot_tests__readarr_tabs_System.snap | 54 + ..._tests__lidarr_ui_renders_library_tab.snap | 2 +- ...rr_ui_renders_library_tab_error_popup.snap | 2 +- ...arr_ui_renders_library_tab_with_error.snap | 2 +- ...ui_renders_notification_success_popup.snap | 2 +- ..._tests__radarr_ui_renders_library_tab.snap | 2 +- ...rr_ui_renders_library_tab_error_popup.snap | 2 +- ...arr_ui_renders_library_tab_with_error.snap | 2 +- ...ui_renders_notification_failure_popup.snap | 2 +- ...ui_renders_notification_success_popup.snap | 2 +- ..._tests__sonarr_ui_renders_library_tab.snap | 2 +- ...rr_ui_renders_library_tab_error_popup.snap | 2 +- ...arr_ui_renders_library_tab_with_error.snap | 2 +- ...ui_renders_notification_success_popup.snap | 2 +- src/utils.rs | 7 + 64 files changed, 6418 insertions(+), 22 deletions(-) create mode 100644 src/app/readarr/mod.rs create mode 100644 src/app/readarr/readarr_context_clues.rs create mode 100644 src/app/readarr/readarr_context_clues_tests.rs create mode 100644 src/app/readarr/readarr_tests.rs create mode 100644 src/cli/readarr/mod.rs create mode 100644 src/cli/readarr/readarr_command_tests.rs create mode 100644 src/handlers/readarr_handlers/mod.rs create mode 100644 src/handlers/readarr_handlers/readarr_handler_tests.rs create mode 100644 src/models/readarr_models.rs create mode 100644 src/models/readarr_models_tests.rs create mode 100644 src/models/servarr_data/readarr/mod.rs create mode 100644 src/models/servarr_data/readarr/modals.rs create mode 100644 src/models/servarr_data/readarr/modals_tests.rs create mode 100644 src/models/servarr_data/readarr/readarr_data.rs create mode 100644 src/models/servarr_data/readarr/readarr_data_tests.rs create mode 100644 src/network/readarr_network/mod.rs create mode 100644 src/ui/readarr_ui/library/library_ui_tests.rs create mode 100644 src/ui/readarr_ui/library/mod.rs create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap create mode 100644 src/ui/readarr_ui/mod.rs create mode 100644 src/ui/readarr_ui/readarr_ui_tests.rs create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap diff --git a/README.md b/README.md index 73067db5..9497ecee 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built - [x] ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr) - [x] ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr) - [x] ![lidarr_logo](logos/lidarr.png) [Lidarr](https://wiki.servarr.com/en/lidarr) -- [ ] ![readarr_logo](logos/readarr.png) [Readarr](https://wiki.servarr.com/en/readarr) +- [x] ![readarr_logo](logos/readarr.png) [Readarr](https://wiki.servarr.com/en/readarr) - [ ] ![prowlarr_logo](logos/prowlarr.png) [Prowlarr](https://wiki.servarr.com/en/prowlarr) - [ ] ![whisparr_logo](logos/whisparr.png) [Whisparr](https://wiki.servarr.com/whisparr) - [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/) @@ -256,6 +256,7 @@ Commands: radarr Commands for manging your Radarr instance sonarr Commands for manging your Sonarr instance lidarr Commands for manging your Lidarr instance + readarr Commands for managing your Readarr instance completions Generate shell completions for the Managarr CLI tail-logs Tail Managarr logs config-path Print the full path to the default configuration file. @@ -347,6 +348,7 @@ default port for that respective service. That is: | Radarr | `localhost` | 7878 | | Sonarr | `localhost` | 8989 | | Lidarr | `localhost` | 8686 | +| Readarr | `localhost` | 8787 | > [!TIP] > In general, all Servarrs store their API tokens under Settings -> General -> Security -> API Key in their web UIs. @@ -367,6 +369,10 @@ sonarr: lidarr: # Connect to lidarr running on localhost:8686 - api_token: + +readarr: + # Connect to readarr running on localhost:8787 + - api_token: ``` ## Configuration File Location @@ -591,4 +597,4 @@ Managarr supports using environment variables on startup so you don't have to al * [Tautulli >= v2](https://github.com/Tautulli/Tautulli/wiki/Tautulli-API-Reference) ## Creator -* [Alex Clarke](https://github.com/Dark-Alex-17) +* [Alex Clarke](https://github.com/Dark-Alex-17) \ No newline at end of file diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index ffefe313..c9ae7f18 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -11,6 +11,7 @@ mod tests { use crate::app::{App, AppConfig, Data, ServarrConfig, interpolate_env_vars}; use crate::models::servarr_data::lidarr::lidarr_data::LidarrData; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, ReadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::network::NetworkEvent; @@ -33,11 +34,18 @@ mod tests { ..ServarrConfig::default() }; let sonarr_config_2 = ServarrConfig::default(); + let readarr_config_1 = ServarrConfig { + name: Some("Readarr Test".to_owned()), + weight: Some(2), + ..ServarrConfig::default() + }; + let readarr_config_2 = ServarrConfig::default(); let config = AppConfig { theme: None, radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]), sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]), lidarr: None, + readarr: Some(vec![readarr_config_1.clone(), readarr_config_2.clone()]), }; let expected_tab_routes = vec![ TabRoute { @@ -46,6 +54,12 @@ mod tests { contextual_help: None, config: Some(sonarr_config_1), }, + TabRoute { + title: "Readarr Test".to_owned(), + route: ActiveReadarrBlock::default().into(), + contextual_help: None, + config: Some(readarr_config_1), + }, TabRoute { title: "Radarr 1".to_owned(), route: ActiveRadarrBlock::default().into(), @@ -64,6 +78,12 @@ mod tests { contextual_help: None, config: Some(sonarr_config_2), }, + TabRoute { + title: "Readarr 1".to_owned(), + route: ActiveReadarrBlock::default().into(), + contextual_help: None, + config: Some(readarr_config_2), + }, ]; let app = App::new( @@ -189,6 +209,7 @@ mod tests { let data = Data { lidarr_data: LidarrData::default(), radarr_data, + readarr_data: ReadarrData::default(), sonarr_data, }; let mut app = App { diff --git a/src/app/context_clues.rs b/src/app/context_clues.rs index d7ec1b2e..f870f1c6 100644 --- a/src/app/context_clues.rs +++ b/src/app/context_clues.rs @@ -2,6 +2,7 @@ use crate::app::App; use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding}; use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider; use crate::app::radarr::radarr_context_clues::RadarrContextClueProvider; +use crate::app::readarr::readarr_context_clues::ReadarrContextClueProvider; use crate::app::sonarr::sonarr_context_clues::SonarrContextClueProvider; use crate::models::Route; @@ -23,6 +24,7 @@ impl ContextClueProvider for ServarrContextClueProvider { Route::Radarr(_, _) => RadarrContextClueProvider::get_context_clues(app), Route::Sonarr(_, _) => SonarrContextClueProvider::get_context_clues(app), Route::Lidarr(_, _) => LidarrContextClueProvider::get_context_clues(app), + Route::Readarr(_, _) => ReadarrContextClueProvider::get_context_clues(app), _ => None, } } diff --git a/src/app/context_clues_tests.rs b/src/app/context_clues_tests.rs index a10881e9..a1a6f244 100644 --- a/src/app/context_clues_tests.rs +++ b/src/app/context_clues_tests.rs @@ -9,6 +9,7 @@ mod test { use crate::app::{App, key_binding::DEFAULT_KEYBINDINGS}; use crate::models::servarr_data::ActiveKeybindingBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; #[test] @@ -303,6 +304,16 @@ mod test { assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES,); } + #[test] + fn test_servarr_context_clue_provider_delegates_to_readarr_provider() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + + let context_clues = ServarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES,); + } + #[test] fn test_servarr_context_clue_provider_unsupported_route_returns_none() { let mut app = App::test_default(); diff --git a/src/app/mod.rs b/src/app/mod.rs index a660243d..14b18339 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,6 +17,7 @@ use crate::cli::Command; use crate::models::servarr_data::Notification; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, ReadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::servarr_models::KeybindingItem; use crate::models::stateful_table::StatefulTable; @@ -30,6 +31,7 @@ pub mod key_binding; mod key_binding_tests; pub mod lidarr; pub mod radarr; +pub mod readarr; pub mod sonarr; pub struct App<'a> { @@ -122,6 +124,26 @@ impl App<'_> { server_tabs.extend(lidarr_tabs); } + if let Some(readarr_configs) = config.readarr { + let mut unnamed_idx = 0; + let readarr_tabs = readarr_configs.into_iter().map(|readarr_config| { + let name = if let Some(name) = readarr_config.name.clone() { + name + } else { + unnamed_idx += 1; + format!("Readarr {unnamed_idx}") + }; + + TabRoute { + title: name, + route: ActiveReadarrBlock::Authors.into(), + contextual_help: None, + config: Some(readarr_config), + } + }); + server_tabs.extend(readarr_tabs); + } + let weight_sorted_tabs = server_tabs .into_iter() .sorted_by(|tab1, tab2| { @@ -204,6 +226,7 @@ impl App<'_> { 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, Route::Lidarr(active_lidarr_block, _) => self.lidarr_on_tick(active_lidarr_block).await, + Route::Readarr(active_readarr_block, _) => self.readarr_on_tick(active_readarr_block).await, _ => (), } @@ -300,6 +323,12 @@ impl App<'_> { contextual_help: None, config: Some(ServarrConfig::default()), }, + TabRoute { + title: "Readarr".to_owned(), + route: ActiveReadarrBlock::Authors.into(), + contextual_help: None, + config: Some(ServarrConfig::default()), + }, ]), ..App::default() } @@ -310,6 +339,7 @@ impl App<'_> { data: Data { lidarr_data: LidarrData::test_default_fully_populated(), radarr_data: RadarrData::test_default_fully_populated(), + readarr_data: ReadarrData::test_default_fully_populated(), sonarr_data: SonarrData::test_default_fully_populated(), }, server_tabs: TabState::new(vec![ @@ -331,6 +361,12 @@ impl App<'_> { contextual_help: None, config: Some(ServarrConfig::default()), }, + TabRoute { + title: "Readarr".to_owned(), + route: ActiveReadarrBlock::Authors.into(), + contextual_help: None, + config: Some(ServarrConfig::default()), + }, ]), ..App::default() } @@ -341,6 +377,7 @@ impl App<'_> { pub struct Data<'a> { pub lidarr_data: LidarrData<'a>, pub radarr_data: RadarrData<'a>, + pub readarr_data: ReadarrData<'a>, pub sonarr_data: SonarrData<'a>, } @@ -349,12 +386,17 @@ pub struct AppConfig { pub theme: Option, pub lidarr: Option>, pub radarr: Option>, + pub readarr: Option>, pub sonarr: Option>, } impl AppConfig { pub fn validate(&self, config_path: &str) { - if self.lidarr.is_none() && self.radarr.is_none() && self.sonarr.is_none() { + if self.lidarr.is_none() + && self.radarr.is_none() + && self.sonarr.is_none() + && self.readarr.is_none() + { log_and_print_error(format!( "No Servarrs are configured in the file: {config_path}" )); @@ -372,6 +414,10 @@ impl AppConfig { if let Some(lidarr_configs) = &self.lidarr { lidarr_configs.iter().for_each(|config| config.validate()); } + + if let Some(readarr_configs) = &self.readarr { + readarr_configs.iter().for_each(|config| config.validate()); + } } pub fn verify_config_present_for_cli(&self, command: &Command) { @@ -393,6 +439,10 @@ impl AppConfig { msg("Lidarr"); process::exit(1); } + Command::Readarr(_) if self.readarr.is_none() => { + msg("Readarr"); + process::exit(1); + } _ => (), } } @@ -415,6 +465,12 @@ impl AppConfig { lidarr_config.post_process_initialization(); } } + + if let Some(readarr_configs) = self.readarr.as_mut() { + for readarr_config in readarr_configs { + readarr_config.post_process_initialization(); + } + } } } diff --git a/src/app/readarr/mod.rs b/src/app/readarr/mod.rs new file mode 100644 index 00000000..70f89f2c --- /dev/null +++ b/src/app/readarr/mod.rs @@ -0,0 +1,48 @@ +use super::App; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + +pub mod readarr_context_clues; + +#[cfg(test)] +#[path = "readarr_tests.rs"] +mod readarr_tests; + +impl App<'_> { + pub(super) async fn dispatch_by_readarr_block( + &mut self, + _active_readarr_block: &ActiveReadarrBlock, + ) { + self.check_for_readarr_prompt_action().await; + self.reset_tick_count(); + } + + async fn check_for_readarr_prompt_action(&mut self) { + if self.data.readarr_data.prompt_confirm { + self.data.readarr_data.prompt_confirm = false; + if let Some(readarr_event) = self.data.readarr_data.prompt_confirm_action.take() { + self.dispatch_network_event(readarr_event.into()).await; + self.should_refresh = true; + } + } + } + + pub(super) async fn readarr_on_tick(&mut self, active_readarr_block: ActiveReadarrBlock) { + if self.is_first_render { + self.dispatch_by_readarr_block(&active_readarr_block).await; + self.is_first_render = false; + return; + } + + if self.should_refresh { + self.dispatch_by_readarr_block(&active_readarr_block).await; + } + + if self.is_routing { + if !self.should_refresh { + self.cancellation_token.cancel(); + } else { + self.dispatch_by_readarr_block(&active_readarr_block).await; + } + } + } +} diff --git a/src/app/readarr/readarr_context_clues.rs b/src/app/readarr/readarr_context_clues.rs new file mode 100644 index 00000000..44f3a2bf --- /dev/null +++ b/src/app/readarr/readarr_context_clues.rs @@ -0,0 +1,205 @@ +use crate::app::App; +use crate::app::context_clues::{ + BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider, + SYSTEM_TASKS_CONTEXT_CLUES, +}; +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, + BOOK_DETAILS_BLOCKS, EDIT_AUTHOR_BLOCKS, EDIT_INDEXER_BLOCKS, EDITION_DETAILS_BLOCKS, + INDEXER_SETTINGS_BLOCKS, +}; + +#[cfg(test)] +#[path = "readarr_context_clues_tests.rs"] +mod readarr_context_clues_tests; + +pub static AUTHORS_CONTEXT_CLUES: [ContextClue; 10] = [ + (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), + ( + DEFAULT_KEYBINDINGS.toggle_monitoring, + DEFAULT_KEYBINDINGS.toggle_monitoring.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.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.esc, "cancel filter"), +]; + +pub static ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "edit search"), +]; + +pub static AUTHOR_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.edit, "edit author"), + (DEFAULT_KEYBINDINGS.delete, "delete book"), + ( + DEFAULT_KEYBINDINGS.toggle_monitoring, + "toggle book monitoring", + ), + (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 AUTHOR_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.edit, "edit author"), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.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 MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES: [ContextClue; 7] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.edit, "edit author"), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), + ( + 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 BOOK_DETAILS_CONTEXT_CLUES: [ContextClue; 6] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), + (DEFAULT_KEYBINDINGS.submit, "edition details"), + (DEFAULT_KEYBINDINGS.delete, "delete book file"), +]; + +pub static BOOK_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [ + ( + 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.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), +]; + +pub static MANUAL_BOOK_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [ + ( + 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.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static EDITION_DETAILS_CONTEXT_CLUES: [ContextClue; 2] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub(in crate::app) struct ReadarrContextClueProvider; + +impl ContextClueProvider for ReadarrContextClueProvider { + fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> { + let Route::Readarr(active_readarr_block, _context_option) = app.get_current_route() else { + panic!("ReadarrContextClueProvider::get_context_clues called with non-Readarr route"); + }; + + match active_readarr_block { + _ if AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) => app + .data + .readarr_data + .author_info_tabs + .get_active_route_contextual_help(), + _ if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) => app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("book_details_modal is empty") + .book_details_tabs + .get_active_route_contextual_help(), + _ if EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) => { + Some(&EDITION_DETAILS_CONTEXT_CLUES) + } + ActiveReadarrBlock::AddAuthorSearchInput + | ActiveReadarrBlock::AddAuthorEmptySearchResults + | ActiveReadarrBlock::TestAllIndexers + | ActiveReadarrBlock::SystemLogs + | ActiveReadarrBlock::SystemUpdates => Some(&BARE_POPUP_CONTEXT_CLUES), + _ if EDIT_AUTHOR_BLOCKS.contains(&active_readarr_block) + || EDIT_INDEXER_BLOCKS.contains(&active_readarr_block) + || INDEXER_SETTINGS_BLOCKS.contains(&active_readarr_block) + || ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) => + { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } + ActiveReadarrBlock::AddAuthorPrompt + | ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder + | ActiveReadarrBlock::AddAuthorTagsInput + | ActiveReadarrBlock::AddAuthorAlreadyInLibrary => Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES), + _ if ADD_AUTHOR_BLOCKS.contains(&active_readarr_block) => { + Some(&ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES) + } + ActiveReadarrBlock::SystemTasks => Some(&SYSTEM_TASKS_CONTEXT_CLUES), + _ => app + .data + .readarr_data + .main_tabs + .get_active_route_contextual_help(), + } + } +} diff --git a/src/app/readarr/readarr_context_clues_tests.rs b/src/app/readarr/readarr_context_clues_tests.rs new file mode 100644 index 00000000..b0ef4ec0 --- /dev/null +++ b/src/app/readarr/readarr_context_clues_tests.rs @@ -0,0 +1,540 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::app::context_clues::{ + BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider, + SYSTEM_TASKS_CONTEXT_CLUES, + }; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::readarr::readarr_context_clues::{ + ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES, AUTHOR_DETAILS_CONTEXT_CLUES, + AUTHOR_HISTORY_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES, BOOK_DETAILS_CONTEXT_CLUES, + BOOK_HISTORY_CONTEXT_CLUES, EDITION_DETAILS_CONTEXT_CLUES, MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, + MANUAL_BOOK_SEARCH_CONTEXT_CLUES, ReadarrContextClueProvider, + }; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_data::readarr::modals::BookDetailsModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS, EDIT_INDEXER_BLOCKS, + INDEXER_SETTINGS_BLOCKS, ReadarrData, + }; + use rstest::rstest; + + #[test] + fn test_authors_context_clues() { + let mut authors_context_clues_iter = AUTHORS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.toggle_monitoring, + DEFAULT_KEYBINDINGS.toggle_monitoring.desc + ) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, "update all") + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter") + ); + assert_none!(authors_context_clues_iter.next()); + } + + #[test] + fn test_author_details_context_clues() { + let mut author_details_context_clues_iter = AUTHOR_DETAILS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.edit, "edit author") + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.delete, "delete book") + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.toggle_monitoring, + "toggle book monitoring", + ) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(author_details_context_clues_iter.next()); + } + + #[test] + fn test_add_author_search_results_context_clues() { + let mut add_author_search_results_context_clues_iter = + ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + add_author_search_results_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + add_author_search_results_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "edit search") + ); + assert_none!(add_author_search_results_context_clues_iter.next()); + } + + #[test] + #[should_panic( + expected = "ReadarrContextClueProvider::get_context_clues called with non-Readarr route" + )] + fn test_readarr_context_clue_provider_get_context_clues_non_readarr_route() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::default().into()); + + ReadarrContextClueProvider::get_context_clues(&mut app); + } + + #[test] + fn test_author_history_context_clues() { + let mut author_history_context_clues_iter = AUTHOR_HISTORY_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.edit, "edit author") + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter/close") + ); + assert_none!(author_history_context_clues_iter.next()); + } + + #[test] + fn test_manual_author_search_context_clues() { + let mut manual_author_search_context_clues_iter = MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.edit, "edit author") + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc) + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(manual_author_search_context_clues_iter.next()); + } + + #[test] + fn test_book_details_context_clues() { + let mut book_details_context_clues_iter = BOOK_DETAILS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "edition details") + ); + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.delete, "delete book file") + ); + assert_none!(book_details_context_clues_iter.next()); + } + + #[test] + fn test_book_history_context_clues() { + let mut book_history_context_clues_iter = BOOK_HISTORY_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter/close") + ); + assert_none!(book_history_context_clues_iter.next()); + } + + #[test] + fn test_manual_book_search_context_clues() { + let mut manual_book_search_context_clues_iter = MANUAL_BOOK_SEARCH_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(manual_book_search_context_clues_iter.next()); + } + + #[test] + fn test_edition_details_context_clues() { + let mut edition_details_context_clues_iter = EDITION_DETAILS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + edition_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + edition_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(edition_details_context_clues_iter.next()); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::AuthorDetails, &AUTHOR_DETAILS_CONTEXT_CLUES)] + #[case(1, ActiveReadarrBlock::AuthorHistory, &AUTHOR_HISTORY_CONTEXT_CLUES)] + #[case(2, ActiveReadarrBlock::ManualAuthorSearch, &MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES)] + fn test_readarr_context_clue_provider_author_info_tabs( + #[case] index: usize, + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] expected_context_clues: &[ContextClue], + ) { + let mut app = App::test_default(); + app.data.readarr_data = ReadarrData::default(); + app.data.readarr_data.author_info_tabs.set_index(index); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, expected_context_clues); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::BookDetails, &BOOK_DETAILS_CONTEXT_CLUES)] + #[case(1, ActiveReadarrBlock::BookHistory, &BOOK_HISTORY_CONTEXT_CLUES)] + #[case(2, ActiveReadarrBlock::ManualBookSearch, &MANUAL_BOOK_SEARCH_CONTEXT_CLUES)] + fn test_readarr_context_clue_provider_book_details_tabs( + #[case] index: usize, + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] expected_context_clues: &[ContextClue], + ) { + let mut app = App::test_default(); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal.book_details_tabs.set_index(index); + let readarr_data = ReadarrData { + book_details_modal: Some(book_details_modal), + ..ReadarrData::default() + }; + app.data.readarr_data = readarr_data; + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, expected_context_clues); + } + + #[test] + fn test_readarr_context_clue_provider_book_edition_details_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &EDITION_DETAILS_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_authors_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_authors_sort_prompt_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorsSortPrompt.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_search_authors_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::SearchAuthors.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_filter_authors_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::FilterAuthors.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES); + } + + #[rstest] + fn test_readarr_context_clue_provider_bare_popup_context_clues( + #[values( + ActiveReadarrBlock::AddAuthorSearchInput, + ActiveReadarrBlock::AddAuthorEmptySearchResults, + ActiveReadarrBlock::TestAllIndexers, + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_confirmation_prompt_popup_clues_edit_indexer_blocks() { + let mut blocks = EDIT_AUTHOR_BLOCKS.to_vec(); + blocks.extend(ADD_ROOT_FOLDER_BLOCKS); + blocks.extend(INDEXER_SETTINGS_BLOCKS); + blocks.extend(EDIT_INDEXER_BLOCKS); + + for active_readarr_block in blocks { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + } + + #[test] + fn test_readarr_context_clue_provider_add_author_search_results_context_clues() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES); + } + + #[rstest] + fn test_readarr_context_clue_provider_confirmation_prompt_context_clues_add_author_blocks( + #[values( + ActiveReadarrBlock::AddAuthorPrompt, + ActiveReadarrBlock::AddAuthorSelectMonitor, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + ActiveReadarrBlock::AddAuthorTagsInput, + ActiveReadarrBlock::AddAuthorAlreadyInLibrary + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_system_tasks_clues() { + let mut app = App::test_default(); + + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES); + } +} diff --git a/src/app/readarr/readarr_tests.rs b/src/app/readarr/readarr_tests.rs new file mode 100644 index 00000000..1aa8a0cc --- /dev/null +++ b/src/app/readarr/readarr_tests.rs @@ -0,0 +1,112 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn test_dispatch_by_readarr_block_resets_the_tick_count() { + let mut app = App { + tick_count: 2, + ..App::test_default() + }; + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Authors) + .await; + + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_readarr_block_clears_the_pending_prompt_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Authors) + .await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.should_refresh); + } + + #[tokio::test] + async fn test_check_for_readarr_prompt_action_no_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = false; + + app.check_for_readarr_prompt_action().await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.should_refresh); + } + + #[tokio::test] + async fn test_check_for_readarr_prompt_action_with_no_prompt_confirm_action() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + + app.check_for_readarr_prompt_action().await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + } + + #[tokio::test] + async fn test_readarr_on_tick_first_render() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.is_first_render = true; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.is_first_render); + } + + #[tokio::test] + async fn test_readarr_on_tick_routing() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.is_routing = true; + app.should_refresh = true; + app.is_first_render = false; + app.tick_count = 1; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.cancellation_token.is_cancelled()); + } + + #[tokio::test] + async fn test_readarr_on_tick_routing_while_long_request_is_running_should_cancel_request() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.is_routing = true; + app.should_refresh = false; + app.is_first_render = false; + app.tick_count = 1; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert!(app.cancellation_token.is_cancelled()); + } + + #[tokio::test] + async fn test_readarr_on_tick_should_refresh() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.should_refresh = true; + app.is_first_render = false; + app.tick_count = 1; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert!(app.should_refresh); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 858952bc..1492b53d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,7 @@ use clap_complete::Shell; use indoc::indoc; use lidarr::{LidarrCliHandler, LidarrCommand}; use radarr::{RadarrCliHandler, RadarrCommand}; +use readarr::{ReadarrCliHandler, ReadarrCommand}; use sonarr::{SonarrCliHandler, SonarrCommand}; use tokio::sync::Mutex; @@ -13,6 +14,7 @@ use crate::{app::App, network::NetworkTrait}; pub mod lidarr; pub mod radarr; +pub mod readarr; pub mod sonarr; #[cfg(test)] @@ -30,6 +32,9 @@ pub enum Command { #[command(subcommand, about = "Commands for manging your Lidarr instance")] Lidarr(LidarrCommand), + #[command(subcommand, about = "Commands for managing your Readarr instance")] + Readarr(ReadarrCommand), + #[command( arg_required_else_help = true, about = "Generate shell completions for the Managarr CLI" @@ -78,6 +83,11 @@ pub(crate) async fn handle_command( .handle() .await? } + Command::Readarr(readarr_command) => { + ReadarrCliHandler::with(app, readarr_command, network) + .handle() + .await? + } _ => String::new(), }; diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs new file mode 100644 index 00000000..7797af94 --- /dev/null +++ b/src/cli/readarr/mod.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use super::{CliCommandHandler, Command}; +use crate::{app::App, network::NetworkTrait}; + +#[cfg(test)] +#[path = "readarr_command_tests.rs"] +mod readarr_command_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrCommand {} + +impl From for Command { + fn from(readarr_command: ReadarrCommand) -> Command { + Command::Readarr(readarr_command) + } +} + +pub(super) struct ReadarrCliHandler<'a, 'b> { + app: &'a Arc>>, + command: ReadarrCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: ReadarrCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrCliHandler { + app, + command, + network, + } + } + + async fn handle(self) -> Result { + match self.command {} + } +} diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs new file mode 100644 index 00000000..f07a7d9b --- /dev/null +++ b/src/cli/readarr/readarr_command_tests.rs @@ -0,0 +1,15 @@ +#[cfg(test)] +mod tests { + mod cli { + use clap::CommandFactory; + + use crate::Cli; + + #[test] + fn test_readarr_command_requires_a_subcommand() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr"]); + + assert_err!(&result); + } + } +} diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index 9ce97089..51519b95 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -22,6 +22,7 @@ mod tests { use crate::models::Route; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_data::{ActiveKeybindingBlock, Notification}; use crate::models::servarr_models::KeybindingItem; @@ -61,9 +62,10 @@ mod tests { } #[rstest] - #[case(0, ActiveLidarrBlock::Artists, ActiveSonarrBlock::Series)] + #[case(0, ActiveReadarrBlock::Authors, ActiveSonarrBlock::Series)] #[case(1, ActiveRadarrBlock::Movies, ActiveLidarrBlock::Artists)] - #[case(2, ActiveSonarrBlock::Series, ActiveRadarrBlock::Movies)] + #[case(2, ActiveSonarrBlock::Series, ActiveReadarrBlock::Authors)] + #[case(3, ActiveLidarrBlock::Artists, ActiveRadarrBlock::Movies)] fn test_handle_change_tabs( #[case] index: usize, #[case] left_block: T, @@ -226,6 +228,22 @@ mod tests { assert!(!app.data.sonarr_data.prompt_confirm); } + #[rstest] + fn test_handle_prompt_toggle_left_right_readarr(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + assert!(!app.data.readarr_data.prompt_confirm); + + handle_prompt_toggle(&mut app, key); + + assert!(app.data.readarr_data.prompt_confirm); + + handle_prompt_toggle(&mut app, key); + + assert!(!app.data.readarr_data.prompt_confirm); + } + #[test] fn test_populate_keymapping_table_global_options() { let expected_keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES) diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index bf72eb0d..7c6a3402 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,6 @@ use lidarr_handlers::LidarrHandler; use radarr_handlers::RadarrHandler; +use readarr_handlers::ReadarrHandler; use sonarr_handlers::SonarrHandler; use crate::app::App; @@ -18,6 +19,7 @@ use crate::models::{HorizontallyScrollableText, Route}; mod keybinding_handler; mod lidarr_handlers; mod radarr_handlers; +mod readarr_handlers; mod sonarr_handlers; #[cfg(test)] @@ -132,6 +134,9 @@ pub fn handle_events(key: Key, app: &mut App<'_>) { Route::Lidarr(active_lidarr_block, context) => { LidarrHandler::new(key, app, active_lidarr_block, context).handle() } + Route::Readarr(active_readarr_block, context) => { + ReadarrHandler::new(key, app, active_readarr_block, context).handle() + } _ => (), } } @@ -197,6 +202,9 @@ fn handle_prompt_toggle(app: &mut App<'_>, key: Key) { Route::Lidarr(_, _) => { app.data.lidarr_data.prompt_confirm = !app.data.lidarr_data.prompt_confirm } + Route::Readarr(_, _) => { + app.data.readarr_data.prompt_confirm = !app.data.readarr_data.prompt_confirm + } _ => (), }, _ => (), diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs new file mode 100644 index 00000000..9f389546 --- /dev/null +++ b/src/handlers/readarr_handlers/mod.rs @@ -0,0 +1,74 @@ +use super::KeyEventHandler; +use crate::models::Route; +use crate::{ + app::App, event::Key, models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, +}; + +#[cfg(test)] +#[path = "readarr_handler_tests.rs"] +mod readarr_handler_tests; + +pub(super) struct ReadarrHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, 'b> { + fn accepts(_active_block: ActiveReadarrBlock) -> bool { + true + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> ReadarrHandler<'a, 'b> { + ReadarrHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + 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) {} + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs new file mode 100644 index 00000000..64c4df03 --- /dev/null +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -0,0 +1,52 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::ReadarrHandler; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + #[rstest] + fn test_readarr_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = ReadarrHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_readarr_handler_is_ready() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = ReadarrHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_readarr_handler_accepts() { + for readarr_block in ActiveReadarrBlock::iter() { + assert!(ReadarrHandler::accepts(readarr_block)); + } + } +} diff --git a/src/logos.rs b/src/logos.rs index 0205aff5..fdbfd084 100644 --- a/src/logos.rs +++ b/src/logos.rs @@ -25,8 +25,6 @@ pub const BAZARR_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 READARR_LOGO: &str = "⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ diff --git a/src/main.rs b/src/main.rs index a40173f4..867b519f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -173,7 +173,7 @@ async fn main() -> Result<()> { match args.command { Some(command) => match command { - Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) => { + Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) | Command::Readarr(_) => { if spinner_disabled { start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await; } else { diff --git a/src/models/mod.rs b/src/models/mod.rs index 3eea7c34..867da8b1 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,8 +5,10 @@ use crate::app::ServarrConfig; use crate::app::context_clues::ContextClue; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use lidarr_models::LidarrSerdeable; use radarr_models::RadarrSerdeable; +use readarr_models::ReadarrSerdeable; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde_json::Number; @@ -15,6 +17,7 @@ use sonarr_models::SonarrSerdeable; pub mod lidarr_models; pub mod radarr_models; +pub mod readarr_models; pub mod servarr_data; pub mod servarr_models; pub mod sonarr_models; @@ -32,7 +35,7 @@ mod model_tests; pub enum Route { Radarr(ActiveRadarrBlock, Option), Sonarr(ActiveSonarrBlock, Option), - Readarr, + Readarr(ActiveReadarrBlock, Option), Lidarr(ActiveLidarrBlock, Option), Whisparr, Bazarr, @@ -47,6 +50,7 @@ pub enum Serdeable { Radarr(RadarrSerdeable), Sonarr(SonarrSerdeable), Lidarr(LidarrSerdeable), + Readarr(ReadarrSerdeable), } pub trait Scrollable { diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs new file mode 100644 index 00000000..07a99fec --- /dev/null +++ b/src/models/readarr_models.rs @@ -0,0 +1,501 @@ +use super::{ + HorizontallyScrollableText, Serdeable, + servarr_models::{ + DiskSpace, HostConfig, Indexer, IndexerTestResult, MetadataProfile, QualityProfile, + QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag, + }, +}; +use crate::models::servarr_models::{IndexerSettings, LogResponse, QueueEvent, Update}; +use crate::serde_enum_from; +use chrono::{DateTime, Utc}; +use clap::ValueEnum; +use derivative::Derivative; +use enum_display_style_derive::EnumDisplayStyle; +use serde::{Deserialize, Serialize}; +use serde_json::{Number, Value}; +use std::fmt::{Display, Formatter}; +use strum::{Display, EnumIter}; + +#[cfg(test)] +#[path = "readarr_models_tests.rs"] +mod readarr_models_tests; + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Author { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub author_name: HorizontallyScrollableText, + pub foreign_author_id: String, + pub status: AuthorStatus, + pub overview: Option, + pub author_type: Option, + pub disambiguation: Option, + pub path: String, + pub root_folder_path: Option, + #[serde(deserialize_with = "super::from_i64")] + pub quality_profile_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub metadata_profile_id: i64, + pub monitored: bool, + pub monitor_new_items: NewItemMonitorType, + pub genres: Vec, + pub tags: Vec, + pub ratings: Option, + pub statistics: Option, +} + +#[derive( + Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, Display, EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum AuthorStatus { + #[default] + Continuing, + Ended, + Deleted, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Ratings { + #[serde(deserialize_with = "super::from_i64")] + pub votes: i64, + #[serde(deserialize_with = "super::from_f64")] + pub value: f64, + pub popularity: Option, +} + +impl Eq for Ratings {} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AuthorStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub book_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub book_file_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_book_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub available_book_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size_on_disk: i64, + #[serde(deserialize_with = "super::from_f64")] + pub percent_of_books: f64, +} + +impl Eq for AuthorStatistics {} + +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + clap::ValueEnum, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum NewItemMonitorType { + #[default] + #[display_style(name = "All Books")] + All, + #[display_style(name = "No New Books")] + None, + #[display_style(name = "New Books")] + New, +} + +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + clap::ValueEnum, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum MonitorType { + #[default] + #[display_style(name = "All Books")] + All, + #[display_style(name = "Future Books")] + Future, + #[display_style(name = "Missing Books")] + Missing, + #[display_style(name = "Existing Books")] + Existing, + #[display_style(name = "First Book")] + First, + #[display_style(name = "Latest Book")] + Latest, + None, + Unknown, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadRecord { + pub title: String, + pub status: DownloadStatus, + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub book_id: Option, + pub author_id: Option, + #[serde(deserialize_with = "super::from_f64")] + pub size: f64, + #[serde(deserialize_with = "super::from_f64")] + pub sizeleft: f64, + pub output_path: Option, + #[serde(default)] + pub indexer: String, + pub download_client: Option, +} + +impl Eq for DownloadRecord {} + +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum DownloadStatus { + #[default] + Unknown, + Queued, + Paused, + Downloading, + Completed, + Failed, + Warning, + Delay, + #[display_style(name = "Download Client Unavailable")] + DownloadClientUnavailable, + Fallback, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadsResponse { + pub records: Vec, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddAuthorSearchResult { + pub foreign_author_id: String, + pub author_name: HorizontallyScrollableText, + pub status: AuthorStatus, + #[serde(default)] + pub ended: bool, + pub overview: Option, + pub author_type: Option, + pub disambiguation: Option, + pub genres: Vec, + pub ratings: Option, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Book { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub title: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_i64")] + pub author_id: i64, + pub foreign_book_id: String, + pub monitored: bool, + #[serde(default)] + pub any_edition_ok: bool, + pub page_count: Option, + pub ratings: Option, + pub release_date: Option>, + pub statistics: Option, + pub grabbed: bool, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BookStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub book_file_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_book_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size_on_disk: i64, + #[serde(deserialize_with = "super::from_f64")] + pub percent_of_books: f64, +} + +impl Eq for BookStatistics {} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Edition { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub book_id: i64, + pub foreign_edition_id: String, + pub monitored: bool, + pub is_ebook: bool, + pub title: String, + pub language: Option, + pub overview: Option, + pub format: Option, + pub publisher: Option, + pub page_count: Option, + pub release_date: Option>, + pub isbn13: Option, + pub asin: Option, + pub ratings: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrHistoryWrapper { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrHistoryData { + pub indexer: Option, + pub release_group: Option, + pub nzb_info_url: Option, + pub download_client_name: Option, + pub download_client: Option, + pub age: Option, + pub published_date: Option>, + pub message: Option, + pub reason: Option, + pub dropped_path: Option, + pub imported_path: Option, + pub source_path: Option, + pub path: Option, + pub status_messages: Option, +} + +#[derive( + Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Display, EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum ReadarrHistoryEventType { + #[default] + Unknown, + Grabbed, + #[display_style(name = "Author Folder Imported")] + AuthorFolderImported, + #[display_style(name = "Book Import Incomplete")] + BookImportIncomplete, + #[display_style(name = "Download Ignored")] + DownloadIgnored, + #[display_style(name = "Download Imported")] + DownloadImported, + #[display_style(name = "Download Failed")] + DownloadFailed, + #[display_style(name = "Book File Deleted")] + BookFileDeleted, + #[display_style(name = "Book File Imported")] + BookFileImported, + #[display_style(name = "Book File Renamed")] + BookFileRenamed, + #[display_style(name = "Book File Retagged")] + BookFileRetagged, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrHistoryItem { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub author_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub book_id: i64, + pub source_title: HorizontallyScrollableText, + #[serde(default)] + pub quality: QualityWrapper, + pub date: DateTime, + pub event_type: ReadarrHistoryEventType, + #[serde(default)] + pub data: ReadarrHistoryData, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrTask { + pub name: String, + pub task_name: ReadarrTaskName, + #[serde(deserialize_with = "super::from_i64")] + pub interval: i64, + pub last_execution: DateTime, + pub last_duration: String, + pub next_execution: DateTime, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy, ValueEnum)] +#[serde(rename_all = "PascalCase")] +pub enum ReadarrTaskName { + #[default] + ApplicationUpdateCheck, + Backup, + CheckHealth, + Housekeeping, + ImportListSync, + MessagingCleanup, + RefreshAuthor, + RefreshMonitoredDownloads, + RescanFolders, + RssSync, +} + +impl Display for ReadarrTaskName { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let task_name = serde_json::to_string(&self) + .expect("Unable to serialize task name") + .replace('"', ""); + write!(f, "{task_name}") + } +} + +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[serde(default)] +pub struct ReadarrRelease { + pub guid: String, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub age: i64, + pub title: HorizontallyScrollableText, + pub author_name: Option, + pub book_title: Option, + pub indexer: String, + #[serde(deserialize_with = "super::from_i64")] + pub indexer_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub rejected: bool, + pub rejections: Option>, + pub seeders: Option, + pub leechers: Option, + pub quality: QualityWrapper, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BlocklistItem { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub author_id: i64, + pub book_ids: Option>, + pub source_title: String, + pub quality: QualityWrapper, + pub date: DateTime, + pub protocol: String, + pub indexer: String, + pub message: String, + pub author: Author, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct BlocklistResponse { + pub records: Vec, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BookFile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub author_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub book_id: i64, + pub path: String, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub date_added: DateTime, + pub quality: QualityWrapper, + pub media_info: Option, + pub quality_cutoff_not_met: bool, +} + +#[derive(Serialize, Deserialize, Derivative, Debug, Clone, PartialEq, Eq)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +pub struct MediaInfo { + pub audio_bit_rate: Option, + #[serde(deserialize_with = "super::from_i64")] + pub audio_channels: i64, + pub audio_codec: Option, + pub audio_bits: Option, + pub audio_sample_rate: Option, +} + +impl From for Serdeable { + fn from(value: ReadarrSerdeable) -> Serdeable { + Serdeable::Readarr(value) + } +} + +serde_enum_from!( + ReadarrSerdeable { + AddAuthorSearchResults(Vec), + Author(Author), + Authors(Vec), + BlocklistResponse(BlocklistResponse), + Book(Book), + BookFiles(Vec), + Books(Vec), + DiskSpaces(Vec), + DownloadsResponse(DownloadsResponse), + Editions(Vec), + HostConfig(HostConfig), + Indexers(Vec), + IndexerSettings(IndexerSettings), + IndexerTestResults(Vec), + LogResponse(LogResponse), + MetadataProfiles(Vec), + QualityProfiles(Vec), + QueueEvents(Vec), + ReadarrHistoryItems(Vec), + ReadarrHistoryWrapper(ReadarrHistoryWrapper), + Releases(Vec), + RootFolders(Vec), + SecurityConfig(SecurityConfig), + SystemStatus(SystemStatus), + Tag(Tag), + Tags(Vec), + Tasks(Vec), + Updates(Vec), + Value(Value), + } +); diff --git a/src/models/readarr_models_tests.rs b/src/models/readarr_models_tests.rs new file mode 100644 index 00000000..fe163400 --- /dev/null +++ b/src/models/readarr_models_tests.rs @@ -0,0 +1,1022 @@ +#[cfg(test)] +mod tests { + use chrono::Utc; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::json; + + use crate::models::readarr_models::{ + AddAuthorSearchResult, BlocklistItem, BlocklistResponse, Book, BookFile, DownloadRecord, + DownloadStatus, DownloadsResponse, Edition, MediaInfo, MonitorType, NewItemMonitorType, + ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrHistoryWrapper, ReadarrRelease, + ReadarrTask, ReadarrTaskName, + }; + use crate::models::servarr_models::{ + DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, + MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, + Update, + }; + use crate::models::{ + Serdeable, + readarr_models::{Author, AuthorStatistics, AuthorStatus, Ratings, ReadarrSerdeable}, + }; + + #[test] + fn test_author_status_default() { + assert_eq!(AuthorStatus::default(), AuthorStatus::Continuing); + } + + #[test] + fn test_new_item_monitor_type_display() { + assert_str_eq!(NewItemMonitorType::All.to_string(), "all"); + assert_str_eq!(NewItemMonitorType::None.to_string(), "none"); + assert_str_eq!(NewItemMonitorType::New.to_string(), "new"); + } + + #[test] + fn test_new_item_monitor_type_to_display_str() { + assert_str_eq!(NewItemMonitorType::All.to_display_str(), "All Books"); + assert_str_eq!(NewItemMonitorType::None.to_display_str(), "No New Books"); + assert_str_eq!(NewItemMonitorType::New.to_display_str(), "New Books"); + } + + #[test] + fn test_monitor_type_display() { + assert_str_eq!(MonitorType::All.to_string(), "all"); + assert_str_eq!(MonitorType::Future.to_string(), "future"); + assert_str_eq!(MonitorType::Missing.to_string(), "missing"); + assert_str_eq!(MonitorType::Existing.to_string(), "existing"); + assert_str_eq!(MonitorType::First.to_string(), "first"); + assert_str_eq!(MonitorType::Latest.to_string(), "latest"); + assert_str_eq!(MonitorType::None.to_string(), "none"); + assert_str_eq!(MonitorType::Unknown.to_string(), "unknown"); + } + + #[test] + fn test_monitor_type_to_display_str() { + assert_str_eq!(MonitorType::All.to_display_str(), "All Books"); + assert_str_eq!(MonitorType::Future.to_display_str(), "Future Books"); + assert_str_eq!(MonitorType::Missing.to_display_str(), "Missing Books"); + assert_str_eq!(MonitorType::Existing.to_display_str(), "Existing Books"); + assert_str_eq!(MonitorType::First.to_display_str(), "First Book"); + assert_str_eq!(MonitorType::Latest.to_display_str(), "Latest Book"); + assert_str_eq!(MonitorType::None.to_display_str(), "None"); + assert_str_eq!(MonitorType::Unknown.to_display_str(), "Unknown"); + } + + #[test] + fn test_readarr_serdeable_from() { + let readarr_serdeable = ReadarrSerdeable::Value(json!({})); + + let serdeable: Serdeable = Serdeable::from(readarr_serdeable.clone()); + + assert_eq!(serdeable, Serdeable::Readarr(readarr_serdeable)); + } + + #[test] + fn test_readarr_serdeable_from_unit() { + let readarr_serdeable = ReadarrSerdeable::from(()); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Value(json!({}))); + } + + #[test] + fn test_readarr_serdeable_from_value() { + let value = json!({"test": "test"}); + + let readarr_serdeable: ReadarrSerdeable = value.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Value(value)); + } + + #[test] + fn test_readarr_serdeable_from_authors() { + let authors = vec![Author { + id: 1, + ..Author::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = authors.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Authors(authors)); + } + + #[test] + fn test_author_deserialization() { + let author_json = json!({ + "id": 1, + "authorName": "Test Author", + "foreignAuthorId": "test-foreign-id", + "status": "continuing", + "overview": "Test overview", + "authorType": "Person", + "disambiguation": "American Novelist", + "path": "/nfs/books/Test Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["Fantasy", "Fiction"], + "tags": [1, 2], + "ratings": { + "votes": 100, + "value": 4.5, + "popularity": 450.0 + }, + "statistics": { + "bookCount": 14, + "bookFileCount": 5, + "totalBookCount": 14, + "availableBookCount": 5, + "sizeOnDisk": 51783488, + "percentOfBooks": 35.71 + } + }); + + let author: Author = serde_json::from_value(author_json).unwrap(); + + assert_eq!(author.id, 1); + assert_str_eq!(author.author_name.text, "Test Author"); + assert_str_eq!(author.foreign_author_id, "test-foreign-id"); + assert_eq!(author.status, AuthorStatus::Continuing); + assert_some_eq_x!(&author.overview, "Test overview"); + assert_some_eq_x!(&author.author_type, "Person"); + assert_some_eq_x!(&author.disambiguation, "American Novelist"); + assert_str_eq!(author.path, "/nfs/books/Test Author"); + assert_some_eq_x!(&author.root_folder_path, "/nfs/books/"); + assert_eq!(author.quality_profile_id, 1); + assert_eq!(author.metadata_profile_id, 1); + assert!(author.monitored); + assert_eq!(author.monitor_new_items, NewItemMonitorType::All); + assert_eq!(author.genres, vec!["Fantasy", "Fiction"]); + assert_eq!(author.tags.len(), 2); + assert_some!(&author.ratings); + assert_some!(&author.statistics); + + let ratings = author.ratings.unwrap(); + assert_eq!(ratings.votes, 100); + assert_eq!(ratings.value, 4.5); + assert_some_eq_x!(ratings.popularity, 450.0); + + let stats = author.statistics.unwrap(); + assert_eq!(stats.book_count, 14); + assert_eq!(stats.book_file_count, 5); + assert_eq!(stats.total_book_count, 14); + assert_eq!(stats.available_book_count, 5); + assert_eq!(stats.size_on_disk, 51783488); + assert_eq!(stats.percent_of_books, 35.71); + } + + #[test] + fn test_author_status_deserialization() { + assert_eq!( + serde_json::from_str::("\"continuing\"").unwrap(), + AuthorStatus::Continuing + ); + assert_eq!( + serde_json::from_str::("\"ended\"").unwrap(), + AuthorStatus::Ended + ); + assert_eq!( + serde_json::from_str::("\"deleted\"").unwrap(), + AuthorStatus::Deleted + ); + } + + #[test] + fn test_ratings_equality() { + let ratings1 = Ratings { + votes: 100, + value: 4.5, + popularity: Some(450.0), + }; + let ratings2 = Ratings { + votes: 100, + value: 4.5, + popularity: Some(450.0), + }; + let ratings3 = Ratings { + votes: 50, + value: 3.0, + popularity: None, + }; + + assert_eq!(ratings1, ratings2); + assert_ne!(ratings1, ratings3); + } + + #[test] + fn test_author_statistics_equality() { + let stats1 = AuthorStatistics { + book_count: 14, + book_file_count: 5, + total_book_count: 14, + available_book_count: 5, + size_on_disk: 51783488, + percent_of_books: 35.71, + }; + let stats2 = AuthorStatistics { + book_count: 14, + book_file_count: 5, + total_book_count: 14, + available_book_count: 5, + size_on_disk: 51783488, + percent_of_books: 35.71, + }; + let stats3 = AuthorStatistics::default(); + + assert_eq!(stats1, stats2); + assert_ne!(stats1, stats3); + } + + #[test] + fn test_author_with_optional_fields_none() { + let author_json = json!({ + "id": 1, + "authorName": "Test Author", + "foreignAuthorId": "", + "status": "continuing", + "path": "", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": false, + "monitorNewItems": "all", + "genres": [], + "tags": [] + }); + + let author: Author = serde_json::from_value(author_json).unwrap(); + + assert_none!(&author.overview); + assert_none!(&author.author_type); + assert_none!(&author.disambiguation); + assert_none!(&author.root_folder_path); + assert_eq!(author.monitor_new_items, NewItemMonitorType::All); + assert_none!(&author.ratings); + assert_none!(&author.statistics); + } + + #[test] + fn test_readarr_serdeable_from_author() { + let author = Author { + id: 1, + ..Author::default() + }; + + let readarr_serdeable: ReadarrSerdeable = author.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Author(author)); + } + + #[test] + fn test_readarr_serdeable_from_blocklist_response() { + let blocklist_response = BlocklistResponse { + records: vec![BlocklistItem { + id: 1, + ..BlocklistItem::default() + }], + }; + + let readarr_serdeable: ReadarrSerdeable = blocklist_response.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::BlocklistResponse(blocklist_response) + ); + } + + #[test] + fn test_readarr_serdeable_from_disk_spaces() { + let disk_spaces = vec![DiskSpace { + path: Some("/path".to_owned()), + free_space: 1, + total_space: 1, + }]; + + let readarr_serdeable: ReadarrSerdeable = disk_spaces.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::DiskSpaces(disk_spaces)); + } + + #[test] + fn test_readarr_serdeable_from_downloads_response() { + let downloads_response = DownloadsResponse { + records: vec![DownloadRecord { + id: 1, + ..DownloadRecord::default() + }], + }; + + let readarr_serdeable: ReadarrSerdeable = downloads_response.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::DownloadsResponse(downloads_response) + ); + } + + #[test] + fn test_readarr_serdeable_from_readarr_history_wrapper() { + let history_wrapper = ReadarrHistoryWrapper { + records: vec![ReadarrHistoryItem { + id: 1, + ..ReadarrHistoryItem::default() + }], + }; + + let readarr_serdeable: ReadarrSerdeable = history_wrapper.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::ReadarrHistoryWrapper(history_wrapper) + ); + } + + #[test] + fn test_readarr_serdeable_from_readarr_history_items() { + let history_items = vec![ReadarrHistoryItem { + id: 1, + ..ReadarrHistoryItem::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = history_items.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::ReadarrHistoryItems(history_items) + ); + } + + #[test] + fn test_readarr_serdeable_from_indexers() { + let indexers = vec![Indexer { + id: 1, + ..Indexer::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = indexers.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Indexers(indexers)); + } + + #[test] + fn test_readarr_serdeable_from_indexer_settings() { + let indexer_settings = IndexerSettings { + id: 1, + ..IndexerSettings::default() + }; + + let readarr_serdeable: ReadarrSerdeable = indexer_settings.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::IndexerSettings(indexer_settings) + ); + } + + #[test] + fn test_readarr_serdeable_from_indexer_test_results() { + let indexer_test_results = vec![IndexerTestResult { + id: 1, + ..IndexerTestResult::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = indexer_test_results.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::IndexerTestResults(indexer_test_results) + ); + } + + #[test] + fn test_readarr_serdeable_from_log_response() { + let log_response = LogResponse { + records: vec![Log { + level: "info".to_owned(), + ..Log::default() + }], + }; + + let readarr_serdeable: ReadarrSerdeable = log_response.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::LogResponse(log_response) + ); + } + + #[test] + fn test_readarr_serdeable_from_metadata_profiles() { + let metadata_profiles = vec![MetadataProfile { + id: 1, + name: "Standard".to_owned(), + }]; + + let readarr_serdeable: ReadarrSerdeable = metadata_profiles.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::MetadataProfiles(metadata_profiles) + ); + } + + #[test] + fn test_readarr_serdeable_from_host_config() { + let host_config = HostConfig { + port: 8787, + ..HostConfig::default() + }; + + let readarr_serdeable: ReadarrSerdeable = host_config.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::HostConfig(host_config)); + } + + #[test] + fn test_readarr_serdeable_from_quality_profiles() { + let quality_profiles = vec![QualityProfile { + id: 1, + name: "Any".to_owned(), + }]; + + let readarr_serdeable: ReadarrSerdeable = quality_profiles.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::QualityProfiles(quality_profiles) + ); + } + + #[test] + fn test_readarr_serdeable_from_queue_events() { + let queue_events = vec![QueueEvent { + trigger: "test".to_owned(), + ..QueueEvent::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = queue_events.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::QueueEvents(queue_events) + ); + } + + #[test] + fn test_readarr_serdeable_from_root_folders() { + let root_folders = vec![RootFolder { + id: 1, + path: "/nfs/books".to_owned(), + accessible: true, + free_space: 1000000, + unmapped_folders: None, + }]; + + let readarr_serdeable: ReadarrSerdeable = root_folders.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::RootFolders(root_folders) + ); + } + + #[test] + fn test_readarr_serdeable_from_releases() { + let releases = vec![ReadarrRelease { + guid: "test".to_owned(), + ..ReadarrRelease::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = releases.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Releases(releases)); + } + + #[test] + fn test_readarr_serdeable_from_security_config() { + let security_config = SecurityConfig { + api_key: "test-key".to_owned(), + ..SecurityConfig::default() + }; + + let readarr_serdeable: ReadarrSerdeable = security_config.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::SecurityConfig(security_config) + ); + } + + #[test] + fn test_readarr_serdeable_from_system_status() { + let system_status = SystemStatus { + version: "1.0.0".to_owned(), + start_time: Utc::now(), + }; + + let readarr_serdeable: ReadarrSerdeable = system_status.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::SystemStatus(system_status) + ); + } + + #[test] + fn test_readarr_serdeable_from_tags() { + let tags = vec![Tag { + id: 1, + label: "fantasy".to_owned(), + }]; + + let readarr_serdeable: ReadarrSerdeable = tags.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Tags(tags)); + } + + #[test] + fn test_readarr_serdeable_from_add_author_search_results() { + let search_results = vec![AddAuthorSearchResult { + foreign_author_id: "test-id".to_owned(), + ..AddAuthorSearchResult::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = search_results.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::AddAuthorSearchResults(search_results) + ); + } + + #[test] + fn test_readarr_serdeable_from_books() { + let books = vec![Book { + id: 1, + ..Book::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = books.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Books(books)); + } + + #[test] + fn test_readarr_serdeable_from_book() { + let book = Book { + id: 1, + ..Book::default() + }; + + let readarr_serdeable: ReadarrSerdeable = book.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Book(book)); + } + + #[test] + fn test_readarr_serdeable_from_editions() { + let editions = vec![Edition { + id: 1, + ..Edition::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = editions.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Editions(editions)); + } + + #[test] + fn test_readarr_serdeable_from_book_files() { + let book_files = vec![BookFile { + id: 1, + media_info: Some(MediaInfo { + audio_channels: 2, + ..MediaInfo::default() + }), + ..BookFile::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = book_files.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::BookFiles(book_files)); + } + + #[test] + fn test_readarr_serdeable_from_tasks() { + let tasks = vec![ReadarrTask { + name: "test".to_owned(), + ..ReadarrTask::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = tasks.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Tasks(tasks)); + } + + #[test] + fn test_readarr_serdeable_from_updates() { + let updates = vec![Update { + version: "test".to_owned(), + ..Update::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = updates.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Updates(updates)); + } + + #[test] + fn test_author_status_display() { + assert_str_eq!(AuthorStatus::Continuing.to_string(), "continuing"); + assert_str_eq!(AuthorStatus::Ended.to_string(), "ended"); + assert_str_eq!(AuthorStatus::Deleted.to_string(), "deleted"); + } + + #[test] + fn test_author_status_to_display_str() { + assert_str_eq!(AuthorStatus::Continuing.to_display_str(), "Continuing"); + assert_str_eq!(AuthorStatus::Ended.to_display_str(), "Ended"); + assert_str_eq!(AuthorStatus::Deleted.to_display_str(), "Deleted"); + } + + #[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_readarr_history_event_type_display() { + assert_str_eq!(ReadarrHistoryEventType::Unknown.to_string(), "unknown"); + assert_str_eq!(ReadarrHistoryEventType::Grabbed.to_string(), "grabbed"); + assert_str_eq!( + ReadarrHistoryEventType::AuthorFolderImported.to_string(), + "authorFolderImported" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookImportIncomplete.to_string(), + "bookImportIncomplete" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadIgnored.to_string(), + "downloadIgnored" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadImported.to_string(), + "downloadImported" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadFailed.to_string(), + "downloadFailed" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileDeleted.to_string(), + "bookFileDeleted" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileImported.to_string(), + "bookFileImported" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileRenamed.to_string(), + "bookFileRenamed" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileRetagged.to_string(), + "bookFileRetagged" + ); + } + + #[test] + fn test_readarr_history_event_type_to_display_str() { + assert_str_eq!(ReadarrHistoryEventType::Unknown.to_display_str(), "Unknown"); + assert_str_eq!(ReadarrHistoryEventType::Grabbed.to_display_str(), "Grabbed"); + assert_str_eq!( + ReadarrHistoryEventType::AuthorFolderImported.to_display_str(), + "Author Folder Imported" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookImportIncomplete.to_display_str(), + "Book Import Incomplete" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadIgnored.to_display_str(), + "Download Ignored" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadImported.to_display_str(), + "Download Imported" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadFailed.to_display_str(), + "Download Failed" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileDeleted.to_display_str(), + "Book File Deleted" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileImported.to_display_str(), + "Book File Imported" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileRenamed.to_display_str(), + "Book File Renamed" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileRetagged.to_display_str(), + "Book File Retagged" + ); + } + + #[test] + fn test_readarr_task_name_display() { + assert_str_eq!( + ReadarrTaskName::ApplicationUpdateCheck.to_string(), + "ApplicationUpdateCheck" + ); + assert_str_eq!(ReadarrTaskName::Backup.to_string(), "Backup"); + assert_str_eq!(ReadarrTaskName::CheckHealth.to_string(), "CheckHealth"); + assert_str_eq!(ReadarrTaskName::Housekeeping.to_string(), "Housekeeping"); + assert_str_eq!( + ReadarrTaskName::ImportListSync.to_string(), + "ImportListSync" + ); + assert_str_eq!( + ReadarrTaskName::MessagingCleanup.to_string(), + "MessagingCleanup" + ); + assert_str_eq!(ReadarrTaskName::RefreshAuthor.to_string(), "RefreshAuthor"); + assert_str_eq!( + ReadarrTaskName::RefreshMonitoredDownloads.to_string(), + "RefreshMonitoredDownloads" + ); + assert_str_eq!(ReadarrTaskName::RescanFolders.to_string(), "RescanFolders"); + assert_str_eq!(ReadarrTaskName::RssSync.to_string(), "RssSync"); + } + + #[test] + fn test_book_deserialization() { + let book_json = json!({ + "id": 1, + "title": "The Name of the Wind", + "authorId": 3, + "foreignBookId": "test-foreign-book-id", + "monitored": true, + "anyEditionOk": true, + "pageCount": 662, + "releaseDate": "2007-03-27T00:00:00Z", + "grabbed": false, + "ratings": { + "votes": 100, + "value": 4.5, + "popularity": 450.0 + }, + "statistics": { + "bookFileCount": 1, + "totalBookCount": 1, + "sizeOnDisk": 51783488, + "percentOfBooks": 100.0 + } + }); + + let book: Book = serde_json::from_value(book_json).unwrap(); + + assert_eq!(book.id, 1); + assert_str_eq!(book.title.text, "The Name of the Wind"); + assert_eq!(book.author_id, 3); + assert_str_eq!(book.foreign_book_id, "test-foreign-book-id"); + assert!(book.monitored); + assert!(book.any_edition_ok); + assert_some_eq_x!(book.page_count, 662); + assert_some!(&book.release_date); + assert!(!book.grabbed); + assert_some!(&book.ratings); + + let stats = book.statistics.unwrap(); + assert_eq!(stats.book_file_count, 1); + assert_eq!(stats.total_book_count, 1); + assert_eq!(stats.size_on_disk, 51783488); + assert_eq!(stats.percent_of_books, 100.0); + } + + #[test] + fn test_book_with_optional_fields_none() { + let book_json = json!({ + "id": 1, + "title": "The Name of the Wind", + "authorId": 3, + "foreignBookId": "", + "monitored": false, + "grabbed": false + }); + + let book: Book = serde_json::from_value(book_json).unwrap(); + + assert!(!book.any_edition_ok); + assert_none!(&book.page_count); + assert_none!(&book.release_date); + assert_none!(&book.ratings); + assert_none!(&book.statistics); + } + + #[test] + fn test_edition_deserialization() { + let edition_json = json!({ + "id": 106, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id", + "monitored": true, + "isEbook": false, + "title": "The Name of the Wind", + "language": "eng", + "overview": "Test overview", + "format": "Hardcover", + "publisher": "DAW Books", + "pageCount": 662, + "releaseDate": "2007-03-27T00:00:00Z", + "isbn13": "9780756404079", + "asin": "0756404070", + "ratings": { + "votes": 100, + "value": 4.5, + "popularity": 450.0 + } + }); + + let edition: Edition = serde_json::from_value(edition_json).unwrap(); + + assert_eq!(edition.id, 106); + assert_eq!(edition.book_id, 1); + assert_str_eq!(edition.foreign_edition_id, "test-foreign-edition-id"); + assert!(edition.monitored); + assert!(!edition.is_ebook); + assert_str_eq!(edition.title, "The Name of the Wind"); + assert_some_eq_x!(&edition.language, "eng"); + assert_some_eq_x!(&edition.overview, "Test overview"); + assert_some_eq_x!(&edition.format, "Hardcover"); + assert_some_eq_x!(&edition.publisher, "DAW Books"); + assert_some_eq_x!(edition.page_count, 662); + assert_some!(&edition.release_date); + assert_some_eq_x!(&edition.isbn13, "9780756404079"); + assert_some_eq_x!(&edition.asin, "0756404070"); + assert_some!(&edition.ratings); + } + + #[test] + fn test_edition_with_optional_fields_none() { + let edition_json = json!({ + "id": 106, + "bookId": 1, + "foreignEditionId": "", + "monitored": false, + "isEbook": true, + "title": "" + }); + + let edition: Edition = serde_json::from_value(edition_json).unwrap(); + + assert_none!(&edition.language); + assert_none!(&edition.overview); + assert_none!(&edition.format); + assert_none!(&edition.publisher); + assert_none!(&edition.page_count); + assert_none!(&edition.release_date); + assert_none!(&edition.isbn13); + assert_none!(&edition.asin); + assert_none!(&edition.ratings); + } + + #[test] + fn test_media_info_deserialization() { + let media_info_json = json!({ + "audioChannels": 2, + "audioBitRate": "99 kbps", + "audioCodec": "AAC", + "audioBits": "", + "audioSampleRate": "44.1kHz" + }); + + let media_info: MediaInfo = serde_json::from_value(media_info_json).unwrap(); + + assert_eq!(media_info.audio_channels, 2); + assert_some_eq_x!(&media_info.audio_bit_rate, "99 kbps"); + assert_some_eq_x!(&media_info.audio_codec, "AAC"); + assert_some_eq_x!(&media_info.audio_bits, ""); + assert_some_eq_x!(&media_info.audio_sample_rate, "44.1kHz"); + } + + #[test] + fn test_download_record_deserialization() { + let download_record_json = json!({ + "title": "Test Download", + "status": "downloading", + "id": 1, + "bookId": 2, + "authorId": 3, + "size": 3543348019.0, + "sizeleft": 1771674009.5, + "outputPath": "/nfs/books/Test Author", + "downloadClient": "transmission" + }); + + let download_record: DownloadRecord = serde_json::from_value(download_record_json).unwrap(); + + assert_str_eq!(download_record.title, "Test Download"); + assert_eq!(download_record.status, DownloadStatus::Downloading); + assert_eq!(download_record.id, 1); + assert_some!(&download_record.book_id); + assert_some!(&download_record.author_id); + assert_eq!(download_record.size, 3543348019.0); + assert_eq!(download_record.sizeleft, 1771674009.5); + assert_some!(&download_record.output_path); + assert!(download_record.indexer.is_empty()); + assert_some_eq_x!(&download_record.download_client, "transmission"); + } + + #[test] + fn test_add_author_search_result_deserialization() { + let search_result_json = json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "status": "continuing", + "ended": false, + "overview": "Test overview", + "authorType": "Person", + "disambiguation": "American Novelist", + "genres": ["Fantasy", "Fiction"], + "ratings": { + "votes": 100, + "value": 4.5, + "popularity": 450.0 + } + }); + + let search_result: AddAuthorSearchResult = serde_json::from_value(search_result_json).unwrap(); + + assert_str_eq!(search_result.foreign_author_id, "test-foreign-id"); + assert_str_eq!(search_result.author_name.text, "Test Author"); + assert_eq!(search_result.status, AuthorStatus::Continuing); + assert!(!search_result.ended); + assert_some_eq_x!(&search_result.overview, "Test overview"); + assert_some_eq_x!(&search_result.author_type, "Person"); + assert_some_eq_x!(&search_result.disambiguation, "American Novelist"); + assert_eq!(search_result.genres, vec!["Fantasy", "Fiction"]); + assert_some!(&search_result.ratings); + + let ratings = search_result.ratings.unwrap(); + assert_eq!(ratings.votes, 100); + assert_eq!(ratings.value, 4.5); + } + + #[test] + fn test_add_author_search_result_with_optional_fields_none() { + let search_result_json = json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "status": "ended", + "genres": [] + }); + + let search_result: AddAuthorSearchResult = serde_json::from_value(search_result_json).unwrap(); + + assert_str_eq!(search_result.foreign_author_id, "test-foreign-id"); + assert_str_eq!(search_result.author_name.text, "Test Author"); + assert_eq!(search_result.status, AuthorStatus::Ended); + assert!(!search_result.ended); + assert_none!(&search_result.overview); + assert_none!(&search_result.author_type); + assert_none!(&search_result.disambiguation); + assert!(search_result.genres.is_empty()); + assert_none!(&search_result.ratings); + } +} diff --git a/src/models/servarr_data/mod.rs b/src/models/servarr_data/mod.rs index c0583806..c329e662 100644 --- a/src/models/servarr_data/mod.rs +++ b/src/models/servarr_data/mod.rs @@ -3,6 +3,7 @@ use crate::models::Route; pub mod lidarr; pub mod modals; pub mod radarr; +pub mod readarr; pub mod sonarr; #[cfg(test)] diff --git a/src/models/servarr_data/readarr/mod.rs b/src/models/servarr_data/readarr/mod.rs new file mode 100644 index 00000000..84c3cdfd --- /dev/null +++ b/src/models/servarr_data/readarr/mod.rs @@ -0,0 +1,2 @@ +pub mod modals; +pub mod readarr_data; diff --git a/src/models/servarr_data/readarr/modals.rs b/src/models/servarr_data/readarr/modals.rs new file mode 100644 index 00000000..30b0a817 --- /dev/null +++ b/src/models/servarr_data/readarr/modals.rs @@ -0,0 +1,271 @@ +use super::readarr_data::{ActiveReadarrBlock, ReadarrData}; +use crate::app::readarr::readarr_context_clues::{ + BOOK_DETAILS_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, MANUAL_BOOK_SEARCH_CONTEXT_CLUES, +}; +use crate::models::readarr_models::{BookFile, Edition, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::modals::EditIndexerModal; +use crate::models::servarr_models::Indexer; +use crate::models::stateful_table::StatefulTable; +use crate::models::{ + HorizontallyScrollableText, ScrollableText, TabRoute, TabState, + readarr_models::{MonitorType, NewItemMonitorType}, + servarr_models::RootFolder, + stateful_list::StatefulList, +}; +use strum::IntoEnumIterator; + +#[cfg(test)] +#[path = "modals_tests.rs"] +mod modals_tests; + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct AddAuthorModal { + pub root_folder_list: StatefulList, + pub monitor_list: StatefulList, + pub monitor_new_items_list: StatefulList, + pub quality_profile_list: StatefulList, + pub metadata_profile_list: StatefulList, + pub tags: HorizontallyScrollableText, +} + +impl From<&ReadarrData<'_>> for AddAuthorModal { + fn from(readarr_data: &ReadarrData<'_>) -> AddAuthorModal { + let mut add_author_modal = AddAuthorModal::default(); + add_author_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_author_modal + .quality_profile_list + .set_items(readarr_data.sorted_quality_profile_names()); + add_author_modal + .metadata_profile_list + .set_items(readarr_data.sorted_metadata_profile_names()); + add_author_modal + .root_folder_list + .set_items(readarr_data.root_folders.items.to_vec()); + + add_author_modal + } +} + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct EditAuthorModal { + pub monitor_list: StatefulList, + pub quality_profile_list: StatefulList, + pub metadata_profile_list: StatefulList, + pub monitored: Option, + pub path: HorizontallyScrollableText, + pub tags: HorizontallyScrollableText, +} + +impl From<&ReadarrData<'_>> for EditAuthorModal { + fn from(readarr_data: &ReadarrData<'_>) -> EditAuthorModal { + let mut edit_author_modal = EditAuthorModal::default(); + let author = readarr_data.authors.current_selection(); + + edit_author_modal + .monitor_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_author_modal.path = author.path.clone().into(); + edit_author_modal.tags = readarr_data.tag_ids_to_display(&author.tags).into(); + edit_author_modal.monitored = Some(author.monitored); + + let monitor_index = edit_author_modal + .monitor_list + .items + .iter() + .position(|m| *m == author.monitor_new_items); + edit_author_modal.monitor_list.state.select(monitor_index); + + edit_author_modal + .quality_profile_list + .set_items(readarr_data.sorted_quality_profile_names()); + let quality_profile_name = readarr_data + .quality_profile_map + .get_by_left(&author.quality_profile_id) + .unwrap(); + let quality_profile_index = edit_author_modal + .quality_profile_list + .items + .iter() + .position(|profile| profile == quality_profile_name); + edit_author_modal + .quality_profile_list + .state + .select(quality_profile_index); + + edit_author_modal + .metadata_profile_list + .set_items(readarr_data.sorted_metadata_profile_names()); + let metadata_profile_name = readarr_data + .metadata_profile_map + .get_by_left(&author.metadata_profile_id) + .unwrap(); + let metadata_profile_index = edit_author_modal + .metadata_profile_list + .items + .iter() + .position(|profile| profile == metadata_profile_name); + edit_author_modal + .metadata_profile_list + .state + .select(metadata_profile_index); + + edit_author_modal + } +} + +impl From<&ReadarrData<'_>> for EditIndexerModal { + fn from(readarr_data: &ReadarrData<'_>) -> EditIndexerModal { + let mut edit_indexer_modal = EditIndexerModal::default(); + let Indexer { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + tags, + fields, + priority, + .. + } = readarr_data.indexers.current_selection(); + let seed_ratio_field_option = fields + .as_ref() + .expect("indexer fields must exist") + .iter() + .find(|field| { + field.name.as_ref().expect("indexer field name must exist") == "seedCriteria.seedRatio" + }); + let seed_ratio_value_option = if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field.value.clone() + } else { + None + }; + + edit_indexer_modal.name = name.clone().expect("indexer name must exist").into(); + 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() + .expect("indexer fields must exist") + .iter() + .find(|field| field.name.as_ref().expect("indexer field name must exist") == "baseUrl") + .expect("baseUrl field must exist") + .value + .clone() + .expect("baseUrl field value must exist") + .as_str() + .expect("baseUrl field value must be a string") + .into(); + edit_indexer_modal.api_key = fields + .as_ref() + .expect("indexer fields must exist") + .iter() + .find(|field| field.name.as_ref().expect("indexer field name must exist") == "apiKey") + .expect("apiKey field must exist") + .value + .clone() + .expect("apiKey field value must exist") + .as_str() + .expect("apiKey field value must be a string") + .into(); + + if let Some(seed_ratio_value) = seed_ratio_value_option { + edit_indexer_modal.seed_ratio = seed_ratio_value + .as_f64() + .expect("Seed ratio value must be a valid f64") + .to_string() + .into(); + } + + edit_indexer_modal.tags = readarr_data.tag_ids_to_display(tags).into(); + + edit_indexer_modal + } +} + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct AddReadarrRootFolderModal { + pub name: HorizontallyScrollableText, + pub path: HorizontallyScrollableText, + pub monitor_list: StatefulList, + pub monitor_new_items_list: StatefulList, + pub quality_profile_list: StatefulList, + pub metadata_profile_list: StatefulList, + pub tags: HorizontallyScrollableText, +} + +impl From<&ReadarrData<'_>> for AddReadarrRootFolderModal { + fn from(readarr_data: &ReadarrData<'_>) -> AddReadarrRootFolderModal { + let mut add_root_folder_modal = AddReadarrRootFolderModal::default(); + add_root_folder_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder_modal + .quality_profile_list + .set_items(readarr_data.sorted_quality_profile_names()); + add_root_folder_modal + .metadata_profile_list + .set_items(readarr_data.sorted_metadata_profile_names()); + + add_root_folder_modal + } +} + +#[cfg_attr(test, derive(Debug))] +pub struct BookDetailsModal { + pub editions: StatefulTable, + pub book_files: StatefulTable, + pub edition_details_modal: Option, + pub book_history: StatefulTable, + pub book_releases: StatefulTable, + pub book_details_tabs: TabState, +} + +impl Default for BookDetailsModal { + fn default() -> BookDetailsModal { + BookDetailsModal { + editions: StatefulTable::default(), + edition_details_modal: None, + book_files: StatefulTable::default(), + book_releases: StatefulTable::default(), + book_history: StatefulTable::default(), + book_details_tabs: TabState::new(vec![ + TabRoute { + title: "Editions".to_string(), + route: ActiveReadarrBlock::BookDetails.into(), + contextual_help: Some(&BOOK_DETAILS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "History".to_string(), + route: ActiveReadarrBlock::BookHistory.into(), + contextual_help: Some(&BOOK_HISTORY_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Manual Search".to_string(), + route: ActiveReadarrBlock::ManualBookSearch.into(), + contextual_help: Some(&MANUAL_BOOK_SEARCH_CONTEXT_CLUES), + config: None, + }, + ]), + } + } +} + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct EditionDetailsModal { + pub edition_details: ScrollableText, +} diff --git a/src/models/servarr_data/readarr/modals_tests.rs b/src/models/servarr_data/readarr/modals_tests.rs new file mode 100644 index 00000000..3d7f7c7a --- /dev/null +++ b/src/models/servarr_data/readarr/modals_tests.rs @@ -0,0 +1,322 @@ +#[cfg(test)] +mod tests { + use crate::app::readarr::readarr_context_clues::{ + BOOK_DETAILS_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, MANUAL_BOOK_SEARCH_CONTEXT_CLUES, + }; + use crate::models::readarr_models::{Author, MonitorType, NewItemMonitorType}; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::modals::{ + AddAuthorModal, AddReadarrRootFolderModal, BookDetailsModal, EditAuthorModal, + EditionDetailsModal, + }; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, ReadarrData}; + use crate::models::servarr_models::{Indexer, IndexerField, RootFolder}; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use serde_json::{Number, Value}; + + #[test] + fn test_add_author_modal_from_readarr_data() { + let mut readarr_data = ReadarrData { + quality_profile_map: BiMap::from_iter([ + (2i64, "Lossless".to_owned()), + (1i64, "Standard".to_owned()), + ]), + metadata_profile_map: BiMap::from_iter([ + (2i64, "None".to_owned()), + (1i64, "Standard".to_owned()), + ]), + ..ReadarrData::default() + }; + let root_folder_1 = RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }; + readarr_data.root_folders.set_items(vec![ + root_folder_1.clone(), + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + + let add_author_modal = AddAuthorModal::from(&readarr_data); + + assert_eq!( + *add_author_modal.monitor_list.current_selection(), + MonitorType::default() + ); + assert_eq!( + *add_author_modal.monitor_new_items_list.current_selection(), + NewItemMonitorType::default() + ); + assert_str_eq!( + add_author_modal.quality_profile_list.current_selection(), + "Standard" + ); + assert_str_eq!( + add_author_modal.metadata_profile_list.current_selection(), + "Standard" + ); + assert_eq!( + add_author_modal.root_folder_list.current_selection(), + &root_folder_1 + ); + assert_is_empty!(add_author_modal.tags.text); + } + + #[test] + fn test_edit_author_modal_from_readarr_data() { + let mut readarr_data = ReadarrData { + quality_profile_map: BiMap::from_iter([ + (1i64, "HD - 1080p".to_owned()), + (2i64, "Any".to_owned()), + ]), + metadata_profile_map: BiMap::from_iter([ + (1i64, "Standard".to_owned()), + (2i64, "None".to_owned()), + ]), + tags_map: BiMap::from_iter([(1i64, "usenet".to_owned())]), + ..ReadarrData::default() + }; + let author = Author { + id: 1, + monitored: true, + monitor_new_items: NewItemMonitorType::New, + quality_profile_id: 1, + metadata_profile_id: 1, + path: "/nfs/books/test_author".to_owned(), + tags: vec![Number::from(1)], + ..Author::default() + }; + readarr_data.authors.set_items(vec![author]); + + let edit_author_modal = EditAuthorModal::from(&readarr_data); + + assert_some_eq_x!(&edit_author_modal.monitored, &true); + assert_eq!( + *edit_author_modal.monitor_list.current_selection(), + NewItemMonitorType::New + ); + assert_str_eq!( + edit_author_modal.quality_profile_list.current_selection(), + "HD - 1080p" + ); + assert_str_eq!( + edit_author_modal.metadata_profile_list.current_selection(), + "Standard" + ); + assert_str_eq!(edit_author_modal.path.text, "/nfs/books/test_author"); + assert_str_eq!(edit_author_modal.tags.text, "usenet"); + } + + #[rstest] + fn test_edit_indexer_modal_from_readarr_data(#[values(true, false)] seed_ratio_present: bool) { + let mut readarr_data = ReadarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..ReadarrData::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 seed_ratio_present { + fields.push(IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(Value::from(1.2f64)), + }); + } + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + priority: 1, + ..Indexer::default() + }; + readarr_data.indexers.set_items(vec![indexer]); + + let edit_indexer_modal = EditIndexerModal::from(&readarr_data); + + assert_str_eq!(edit_indexer_modal.name.text, "Test"); + assert_some_eq_x!(&edit_indexer_modal.enable_rss, &true); + assert_some_eq_x!(&edit_indexer_modal.enable_automatic_search, &true); + assert_some_eq_x!(&edit_indexer_modal.enable_interactive_search, &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"); + + if seed_ratio_present { + assert_str_eq!(edit_indexer_modal.seed_ratio.text, "1.2"); + } else { + assert_is_empty!(edit_indexer_modal.seed_ratio.text); + } + } + + #[test] + fn test_edit_indexer_modal_from_readarr_data_seed_ratio_value_is_none() { + let mut readarr_data = ReadarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..ReadarrData::default() + }; + let 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())), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: None, + }, + ]; + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + priority: 1, + ..Indexer::default() + }; + readarr_data.indexers.set_items(vec![indexer]); + + let edit_indexer_modal = EditIndexerModal::from(&readarr_data); + + assert_str_eq!(edit_indexer_modal.name.text, "Test"); + assert_some_eq_x!(&edit_indexer_modal.enable_rss, &true); + assert_some_eq_x!(&edit_indexer_modal.enable_automatic_search, &true); + assert_some_eq_x!(&edit_indexer_modal.enable_interactive_search, &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_is_empty!(edit_indexer_modal.seed_ratio.text); + } + + #[test] + fn test_add_readarr_root_folder_modal_from_readarr_data() { + let readarr_data = ReadarrData { + quality_profile_map: BiMap::from_iter([ + (2i64, "Lossless".to_owned()), + (1i64, "Standard".to_owned()), + ]), + metadata_profile_map: BiMap::from_iter([ + (2i64, "None".to_owned()), + (1i64, "Standard".to_owned()), + ]), + ..ReadarrData::default() + }; + + let add_root_folder_modal = AddReadarrRootFolderModal::from(&readarr_data); + + assert_eq!( + *add_root_folder_modal.monitor_list.current_selection(), + MonitorType::default() + ); + assert_eq!( + *add_root_folder_modal + .monitor_new_items_list + .current_selection(), + NewItemMonitorType::default() + ); + assert_str_eq!( + add_root_folder_modal + .quality_profile_list + .current_selection(), + "Standard" + ); + assert_str_eq!( + add_root_folder_modal + .metadata_profile_list + .current_selection(), + "Standard" + ); + assert_is_empty!(add_root_folder_modal.name.text); + assert_is_empty!(add_root_folder_modal.path.text); + assert_is_empty!(add_root_folder_modal.tags.text); + } + + #[test] + fn test_book_details_modal_default() { + let book_details_modal = BookDetailsModal::default(); + + assert_is_empty!(book_details_modal.editions); + assert_none!(book_details_modal.edition_details_modal); + assert_is_empty!(book_details_modal.book_files); + assert_is_empty!(book_details_modal.book_releases); + assert_is_empty!(book_details_modal.book_history); + + assert_eq!(book_details_modal.book_details_tabs.tabs.len(), 3); + + assert_str_eq!( + book_details_modal.book_details_tabs.tabs[0].title, + "Editions" + ); + assert_eq!( + book_details_modal.book_details_tabs.tabs[0].route, + ActiveReadarrBlock::BookDetails.into() + ); + assert_some_eq_x!( + &book_details_modal.book_details_tabs.tabs[0].contextual_help, + &BOOK_DETAILS_CONTEXT_CLUES + ); + assert_none!(book_details_modal.book_details_tabs.tabs[0].config); + + assert_str_eq!( + book_details_modal.book_details_tabs.tabs[1].title, + "History" + ); + assert_eq!( + book_details_modal.book_details_tabs.tabs[1].route, + ActiveReadarrBlock::BookHistory.into() + ); + assert_some_eq_x!( + &book_details_modal.book_details_tabs.tabs[1].contextual_help, + &BOOK_HISTORY_CONTEXT_CLUES + ); + assert_none!(book_details_modal.book_details_tabs.tabs[1].config); + + assert_str_eq!( + book_details_modal.book_details_tabs.tabs[2].title, + "Manual Search" + ); + assert_eq!( + book_details_modal.book_details_tabs.tabs[2].route, + ActiveReadarrBlock::ManualBookSearch.into() + ); + assert_some_eq_x!( + &book_details_modal.book_details_tabs.tabs[2].contextual_help, + &MANUAL_BOOK_SEARCH_CONTEXT_CLUES + ); + assert_none!(book_details_modal.book_details_tabs.tabs[2].config); + } + + #[test] + fn test_edition_details_modal_default() { + let edition_details_modal = EditionDetailsModal::default(); + + assert_is_empty!(edition_details_modal.edition_details); + } +} diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs new file mode 100644 index 00000000..08a98bd3 --- /dev/null +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -0,0 +1,980 @@ +use serde_json::Number; + +use super::modals::{AddAuthorModal, AddReadarrRootFolderModal, BookDetailsModal, EditAuthorModal}; +use crate::app::context_clues::{ + BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, + ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, +}; +use crate::app::readarr::readarr_context_clues::{ + AUTHOR_DETAILS_CONTEXT_CLUES, AUTHOR_HISTORY_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES, + MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, +}; +use crate::models::readarr_models::{BlocklistItem, ReadarrRelease, ReadarrTask}; +use crate::models::servarr_data::modals::EditIndexerModal; +use crate::models::servarr_models::{IndexerSettings, QueueEvent}; +use crate::models::stateful_list::StatefulList; +use crate::models::{ + BlockSelectionState, HorizontallyScrollableText, Route, ScrollableText, TabRoute, TabState, + readarr_models::{AddAuthorSearchResult, Author, Book, DownloadRecord, ReadarrHistoryItem}, + servarr_data::modals::IndexerTestResultModalItem, + servarr_models::{DiskSpace, Indexer, RootFolder}, + stateful_table::StatefulTable, +}; +use crate::network::readarr_network::ReadarrEvent; +use bimap::BiMap; +use chrono::{DateTime, Utc}; +use itertools::Itertools; +use strum::EnumIter; +#[cfg(test)] +use { + super::modals::EditionDetailsModal, + crate::models::readarr_models::{ + AuthorStatus, BookFile, DownloadStatus, Edition, MonitorType, NewItemMonitorType, + ReadarrHistoryEventType, ReadarrTaskName, + }, + crate::models::servarr_models::{IndexerField, Quality, QualityWrapper}, + crate::models::stateful_table::SortOption, + crate::network::servarr_test_utils::{ + diskspace, indexer_settings, indexer_test_result, queued_event, + }, + crate::network::sonarr_network::sonarr_network_test_utils::test_utils::updates, + crate::sort_option, + serde_json::json, + strum::{Display, EnumString, IntoEnumIterator}, +}; + +#[cfg(test)] +#[path = "readarr_data_tests.rs"] +mod readarr_data_tests; + +pub struct ReadarrData<'a> { + pub add_author_modal: Option, + pub add_author_search: Option, + pub add_import_list_exclusion: bool, + pub add_root_folder_modal: Option, + pub add_searched_authors: Option>, + pub author_history: StatefulTable, + pub author_info_tabs: TabState, + pub author_releases: StatefulTable, + pub authors: StatefulTable, + pub blocklist: StatefulTable, + pub book_details_modal: Option, + pub books: StatefulTable, + pub delete_files: bool, + pub disk_space_vec: Vec, + pub downloads: StatefulTable, + pub edit_author_modal: Option, + pub edit_indexer_modal: Option, + pub history: StatefulTable, + pub indexer_settings: Option, + pub indexer_test_all_results: Option>, + pub indexer_test_errors: Option, + pub indexers: StatefulTable, + pub log_details: StatefulList, + pub logs: StatefulList, + pub main_tabs: TabState, + pub metadata_profile_map: BiMap, + pub prompt_confirm: bool, + pub prompt_confirm_action: Option, + pub quality_profile_map: BiMap, + pub queued_events: StatefulTable, + pub root_folders: StatefulTable, + pub selected_block: BlockSelectionState<'a, ActiveReadarrBlock>, + pub start_time: DateTime, + pub tags_map: BiMap, + pub tasks: StatefulTable, + pub updates: ScrollableText, + pub version: String, +} + +impl ReadarrData<'_> { + pub fn reset_delete_preferences(&mut self) { + self.delete_files = false; + self.add_import_list_exclusion = false; + } + + pub fn reset_author_info_tabs(&mut self) { + self.books = StatefulTable::default(); + self.author_releases = StatefulTable::default(); + self.author_history = StatefulTable::default(); + self.author_info_tabs.index = 0; + } + + pub fn tag_ids_to_display(&self, tag_ids: &[Number]) -> String { + tag_ids + .iter() + .filter_map(|id| { + let id = id.as_i64()?; + self.tags_map.get_by_left(&id).cloned() + }) + .collect::>() + .join(", ") + } + + pub fn sorted_quality_profile_names(&self) -> Vec { + self + .quality_profile_map + .iter() + .sorted_by_key(|(id, _)| *id) + .map(|(_, name)| name) + .cloned() + .collect() + } + + pub fn sorted_metadata_profile_names(&self) -> Vec { + self + .metadata_profile_map + .iter() + .sorted_by_key(|(id, _)| *id) + .map(|(_, name)| name) + .cloned() + .collect() + } +} + +impl<'a> Default for ReadarrData<'a> { + fn default() -> ReadarrData<'a> { + ReadarrData { + add_author_modal: None, + add_author_search: None, + add_import_list_exclusion: false, + add_root_folder_modal: None, + add_searched_authors: None, + author_history: StatefulTable::default(), + author_releases: StatefulTable::default(), + authors: StatefulTable::default(), + blocklist: StatefulTable::default(), + book_details_modal: None, + books: StatefulTable::default(), + delete_files: false, + disk_space_vec: Vec::new(), + downloads: StatefulTable::default(), + edit_author_modal: None, + edit_indexer_modal: None, + history: StatefulTable::default(), + indexer_settings: None, + indexer_test_all_results: None, + indexer_test_errors: None, + indexers: StatefulTable::default(), + log_details: StatefulList::default(), + logs: StatefulList::default(), + metadata_profile_map: BiMap::new(), + prompt_confirm: false, + prompt_confirm_action: None, + quality_profile_map: BiMap::new(), + queued_events: StatefulTable::default(), + root_folders: StatefulTable::default(), + selected_block: BlockSelectionState::default(), + start_time: DateTime::default(), + tags_map: BiMap::new(), + tasks: StatefulTable::default(), + updates: ScrollableText::default(), + version: String::new(), + main_tabs: TabState::new(vec![ + TabRoute { + title: "Library".to_string(), + route: ActiveReadarrBlock::Authors.into(), + contextual_help: Some(&AUTHORS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Downloads".to_string(), + route: ActiveReadarrBlock::Downloads.into(), + contextual_help: Some(&DOWNLOADS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Blocklist".to_string(), + route: ActiveReadarrBlock::Blocklist.into(), + contextual_help: Some(&BLOCKLIST_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "History".to_string(), + route: ActiveReadarrBlock::History.into(), + contextual_help: Some(&HISTORY_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Root Folders".to_string(), + route: ActiveReadarrBlock::RootFolders.into(), + contextual_help: Some(&ROOT_FOLDERS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Indexers".to_string(), + route: ActiveReadarrBlock::Indexers.into(), + contextual_help: Some(&INDEXERS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "System".to_string(), + route: ActiveReadarrBlock::System.into(), + contextual_help: Some(&SYSTEM_CONTEXT_CLUES), + config: None, + }, + ]), + author_info_tabs: TabState::new(vec![ + TabRoute { + title: "Books".to_string(), + route: ActiveReadarrBlock::AuthorDetails.into(), + contextual_help: Some(&AUTHOR_DETAILS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "History".to_string(), + route: ActiveReadarrBlock::AuthorHistory.into(), + contextual_help: Some(&AUTHOR_HISTORY_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Author Search".to_string(), + route: ActiveReadarrBlock::ManualAuthorSearch.into(), + contextual_help: Some(&MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES), + config: None, + }, + ]), + } + } +} + +#[cfg(test)] +impl ReadarrData<'_> { + pub fn test_default_fully_populated() -> Self { + let quality_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]); + let metadata_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]); + let tags_map = BiMap::from_iter([(1i64, "alex".to_owned())]); + let root_folder = RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }; + let quality = QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }; + let date = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let author = Author { + id: 1, + author_name: "Test Author".into(), + foreign_author_id: "test-foreign-id".to_owned(), + status: AuthorStatus::Continuing, + overview: Some("some interesting description of the author".to_owned()), + path: "/nfs/books/Test Author".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + monitored: true, + monitor_new_items: NewItemMonitorType::All, + genres: vec!["science fiction".to_owned()], + tags: vec![Number::from(1)], + ..Author::default() + }; + let book = Book { + id: 1, + title: "Test Book".into(), + author_id: 1, + foreign_book_id: "test-foreign-book-id".to_owned(), + monitored: true, + any_edition_ok: true, + page_count: Some(288), + release_date: Some(date), + ..Book::default() + }; + let readarr_history_item = ReadarrHistoryItem { + id: 1, + author_id: 1, + book_id: 1, + source_title: "Test Source Title".into(), + quality: quality.clone(), + date, + event_type: ReadarrHistoryEventType::Grabbed, + ..ReadarrHistoryItem::default() + }; + let torrent_release = ReadarrRelease { + guid: "1234".to_owned(), + protocol: "torrent".to_owned(), + age: 1, + title: "Test Torrent Release".into(), + author_name: Some("Test Author".to_owned()), + book_title: Some("Test Book".to_owned()), + indexer: "kickass torrents".to_owned(), + indexer_id: 1, + size: 1234, + rejected: true, + rejections: Some(vec!["Unknown quality profile".to_owned()]), + seeders: Some(Number::from(2)), + leechers: Some(Number::from(1)), + quality: quality.clone(), + }; + let usenet_release = ReadarrRelease { + guid: "5678".to_owned(), + protocol: "usenet".to_owned(), + title: "Test Usenet Release".into(), + indexer: "DrunkenSlug".to_owned(), + indexer_id: 2, + seeders: None, + leechers: None, + ..torrent_release.clone() + }; + let blocklist_item = BlocklistItem { + id: 1, + author_id: 1, + book_ids: Some(vec![Number::from(1)]), + source_title: "Test Source Title".to_owned(), + quality: quality.clone(), + date, + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + message: "test message".to_owned(), + author: author.clone(), + }; + let download_record = DownloadRecord { + title: "Test Download Title".to_owned(), + status: DownloadStatus::Downloading, + id: 1, + book_id: Some(Number::from(1)), + author_id: Some(Number::from(1)), + size: 3543348019f64, + sizeleft: 1771674009f64, + output_path: Some("/nfs/books/Test Author".into()), + indexer: "kickass torrents".to_owned(), + download_client: Some("transmission".to_owned()), + }; + let indexer = Indexer { + id: 1, + name: Some("Test Indexer".to_owned()), + implementation: Some("Torznab".to_owned()), + implementation_name: Some("Torznab".to_owned()), + config_contract: Some("TorznabSettings".to_owned()), + supports_rss: true, + supports_search: true, + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + protocol: "torrent".to_owned(), + priority: 25, + download_client_id: 0, + tags: vec![Number::from(1)], + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(json!("https://test.com")), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(json!("")), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(json!("1.2")), + }, + ]), + }; + let add_author_search_result = AddAuthorSearchResult { + foreign_author_id: "test-foreign-id".to_owned(), + author_name: "Test Author".into(), + status: AuthorStatus::Continuing, + overview: Some("some interesting description of the author".to_owned()), + genres: vec!["science fiction".to_owned()], + ..AddAuthorSearchResult::default() + }; + let task = ReadarrTask { + name: "Backup".to_owned(), + task_name: ReadarrTaskName::Backup, + interval: 60, + last_execution: date, + last_duration: "00:00:00.5111547".to_owned(), + next_execution: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T22:29:16Z").unwrap()), + }; + let log_line = "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|No list items to process"; + + let mut add_author_modal = AddAuthorModal { + tags: "usenet, testing".into(), + ..AddAuthorModal::default() + }; + add_author_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_author_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned()]); + add_author_modal + .quality_profile_list + .set_items(vec!["Standard".to_owned()]); + add_author_modal + .root_folder_list + .set_items(vec![root_folder.clone()]); + + let mut edit_author_modal = EditAuthorModal { + monitored: Some(true), + path: "/nfs/books".into(), + tags: "alex".into(), + ..EditAuthorModal::default() + }; + edit_author_modal + .monitor_list + .set_items(NewItemMonitorType::iter().collect()); + edit_author_modal + .quality_profile_list + .set_items(vec!["Standard".to_owned()]); + edit_author_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned()]); + + let mut add_root_folder_modal = AddReadarrRootFolderModal { + name: "Test Root Folder".into(), + path: "/nfs/books".into(), + tags: "test".into(), + ..AddReadarrRootFolderModal::default() + }; + add_root_folder_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder_modal + .quality_profile_list + .set_items(vec!["Standard".to_owned()]); + add_root_folder_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned()]); + + let edition_details_modal = EditionDetailsModal { + edition_details: ScrollableText::with_string("Some details".to_owned()), + }; + + let mut book_details_modal = BookDetailsModal { + edition_details_modal: Some(edition_details_modal), + ..BookDetailsModal::default() + }; + book_details_modal.editions.set_items(vec![Edition { + id: 1, + book_id: 1, + foreign_edition_id: "test-foreign-edition-id".to_owned(), + monitored: true, + is_ebook: true, + title: "Test Edition".to_owned(), + page_count: Some(288), + release_date: Some(date), + ..Edition::default() + }]); + book_details_modal.editions.search = Some("edition search".into()); + book_details_modal.book_files.set_items(vec![BookFile { + id: 1, + author_id: 1, + book_id: 1, + path: "/nfs/books/Test Author/Test Book.epub".to_owned(), + size: 3543348019, + date_added: date, + quality: quality.clone(), + media_info: None, + quality_cutoff_not_met: false, + }]); + book_details_modal + .book_history + .set_items(vec![readarr_history_item.clone()]); + book_details_modal.book_history.search = Some("book history search".into()); + book_details_modal.book_history.filter = Some("book history filter".into()); + book_details_modal + .book_history + .sorting(vec![sort_option!(id)]); + book_details_modal + .book_releases + .set_items(vec![torrent_release.clone(), usenet_release.clone()]); + book_details_modal + .book_releases + .sorting(vec![sort_option!(indexer_id)]); + + let edit_indexer_modal = EditIndexerModal { + name: "DrunkenSlug".into(), + enable_rss: Some(true), + enable_automatic_search: Some(true), + enable_interactive_search: Some(true), + url: "http://127.0.0.1:9696/1/".into(), + api_key: "someApiKey".into(), + seed_ratio: "ratio".into(), + tags: "25".into(), + priority: 1, + }; + + let mut indexer_test_all_results = StatefulTable::default(); + indexer_test_all_results.set_items(vec![indexer_test_result()]); + + let mut readarr_data = ReadarrData { + add_author_modal: Some(add_author_modal), + add_root_folder_modal: Some(add_root_folder_modal), + book_details_modal: Some(book_details_modal), + delete_files: true, + disk_space_vec: vec![diskspace()], + edit_author_modal: Some(edit_author_modal), + edit_indexer_modal: Some(edit_indexer_modal), + indexer_settings: Some(indexer_settings()), + indexer_test_all_results: Some(indexer_test_all_results), + indexer_test_errors: Some("error".to_string()), + metadata_profile_map, + quality_profile_map, + start_time: date, + tags_map, + updates: updates(), + version: "1.2.3.4".to_owned(), + ..ReadarrData::default() + }; + readarr_data + .author_history + .set_items(vec![readarr_history_item.clone()]); + readarr_data.author_history.sorting(vec![sort_option!(id)]); + readarr_data.author_history.search = Some("author history search".into()); + readarr_data.author_history.filter = Some("author history filter".into()); + readarr_data + .author_releases + .set_items(vec![torrent_release, usenet_release]); + readarr_data + .author_releases + .sorting(vec![sort_option!(indexer_id)]); + readarr_data.authors.set_items(vec![author]); + readarr_data.authors.sorting(vec![SortOption { + name: "Name", + cmp_fn: Some(|a: &Author, b: &Author| a.author_name.text.cmp(&b.author_name.text)), + }]); + readarr_data.authors.search = Some("author search".into()); + readarr_data.authors.filter = Some("author filter".into()); + readarr_data.blocklist.set_items(vec![blocklist_item]); + readarr_data.blocklist.sorting(vec![sort_option!(id)]); + readarr_data.books.set_items(vec![book]); + readarr_data.books.search = Some("book search".into()); + readarr_data.downloads.set_items(vec![download_record]); + readarr_data.history.set_items(vec![readarr_history_item]); + readarr_data.history.sorting(vec![SortOption { + name: "Date", + cmp_fn: Some(|a: &ReadarrHistoryItem, b: &ReadarrHistoryItem| a.date.cmp(&b.date)), + }]); + readarr_data.history.search = Some("test search".into()); + readarr_data.history.filter = Some("test filter".into()); + readarr_data.indexers.set_items(vec![indexer]); + readarr_data.root_folders.set_items(vec![root_folder]); + readarr_data.queued_events.set_items(vec![queued_event()]); + readarr_data.add_author_search = Some("Test Author".into()); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result]); + readarr_data.add_searched_authors = Some(add_searched_authors); + readarr_data.logs.set_items(vec![log_line.into()]); + readarr_data.log_details.set_items(vec![log_line.into()]); + readarr_data.tasks.set_items(vec![task]); + + readarr_data + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] +#[cfg_attr(test, derive(Display, EnumString))] +pub enum ActiveReadarrBlock { + #[default] + Authors, + AddAuthorAlreadyInLibrary, + AddAuthorConfirmPrompt, + AddAuthorEmptySearchResults, + AddAuthorPrompt, + AddAuthorSearchInput, + AddAuthorSearchResults, + AddAuthorSelectMetadataProfile, + AddAuthorSelectMonitor, + AddAuthorSelectMonitorNewItems, + AddAuthorSelectQualityProfile, + AddAuthorSelectRootFolder, + AddAuthorTagsInput, + AddRootFolderConfirmPrompt, + AddRootFolderNameInput, + AddRootFolderPathInput, + AddRootFolderPrompt, + AddRootFolderSelectMetadataProfile, + AddRootFolderSelectMonitor, + AddRootFolderSelectMonitorNewItems, + AddRootFolderSelectQualityProfile, + AddRootFolderTagsInput, + AllIndexerSettingsPrompt, + AuthorDetails, + AuthorHistory, + AuthorHistoryDetails, + AuthorHistorySortPrompt, + AuthorsSortPrompt, + AutomaticallySearchAuthorPrompt, + AutomaticallySearchBookPrompt, + Blocklist, + BlocklistClearAllItemsPrompt, + BlocklistItemDetails, + BlocklistSortPrompt, + BookDetails, + BookEditionDetails, + BookFileInfo, + BookHistory, + BookHistoryDetails, + BookHistorySortPrompt, + DeleteAuthorConfirmPrompt, + DeleteAuthorPrompt, + DeleteAuthorToggleAddListExclusion, + DeleteAuthorToggleDeleteFile, + DeleteBlocklistItemPrompt, + DeleteBookConfirmPrompt, + DeleteBookFilePrompt, + DeleteBookPrompt, + DeleteBookToggleAddListExclusion, + DeleteBookToggleDeleteFile, + DeleteDownloadPrompt, + DeleteIndexerPrompt, + DeleteRootFolderPrompt, + Downloads, + EditAuthorConfirmPrompt, + EditAuthorPathInput, + EditAuthorPrompt, + EditAuthorSelectMetadataProfile, + EditAuthorSelectMonitorNewItems, + EditAuthorSelectQualityProfile, + EditAuthorTagsInput, + EditAuthorToggleMonitored, + EditIndexerApiKeyInput, + EditIndexerConfirmPrompt, + EditIndexerNameInput, + EditIndexerPriorityInput, + EditIndexerPrompt, + EditIndexerSeedRatioInput, + EditIndexerTagsInput, + EditIndexerToggleEnableAutomaticSearch, + EditIndexerToggleEnableInteractiveSearch, + EditIndexerToggleEnableRss, + EditIndexerUrlInput, + FilterAuthorHistory, + FilterAuthorHistoryError, + FilterAuthors, + FilterAuthorsError, + FilterBookHistory, + FilterBookHistoryError, + FilterHistory, + FilterHistoryError, + History, + HistoryItemDetails, + HistorySortPrompt, + Indexers, + IndexerSettingsConfirmPrompt, + IndexerSettingsMaximumSizeInput, + IndexerSettingsMinimumAgeInput, + IndexerSettingsRetentionInput, + IndexerSettingsRssSyncIntervalInput, + ManualAuthorSearch, + ManualAuthorSearchConfirmPrompt, + ManualAuthorSearchSortPrompt, + ManualBookSearch, + ManualBookSearchConfirmPrompt, + ManualBookSearchSortPrompt, + RootFolders, + SearchAuthorHistory, + SearchAuthorHistoryError, + SearchAuthors, + SearchAuthorsError, + SearchBookHistory, + SearchBookHistoryError, + SearchBooks, + SearchBooksError, + SearchEditions, + SearchEditionsError, + SearchHistory, + SearchHistoryError, + System, + SystemLogs, + SystemQueuedEvents, + SystemTasks, + SystemTaskStartConfirmPrompt, + SystemUpdates, + TestAllIndexers, + TestIndexer, + UpdateAllAuthorsPrompt, + UpdateAndScanAuthorPrompt, + UpdateDownloadsPrompt, +} + +pub static LIBRARY_BLOCKS: [ActiveReadarrBlock; 7] = [ + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::AuthorsSortPrompt, + ActiveReadarrBlock::FilterAuthors, + ActiveReadarrBlock::FilterAuthorsError, + ActiveReadarrBlock::SearchAuthors, + ActiveReadarrBlock::SearchAuthorsError, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, +]; + +pub static AUTHOR_DETAILS_BLOCKS: [ActiveReadarrBlock; 15] = [ + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::AuthorHistoryDetails, + ActiveReadarrBlock::AuthorHistorySortPrompt, + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::FilterAuthorHistory, + ActiveReadarrBlock::FilterAuthorHistoryError, + ActiveReadarrBlock::ManualAuthorSearch, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + ActiveReadarrBlock::ManualAuthorSearchSortPrompt, + ActiveReadarrBlock::SearchBooks, + ActiveReadarrBlock::SearchBooksError, + ActiveReadarrBlock::SearchAuthorHistory, + ActiveReadarrBlock::SearchAuthorHistoryError, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, +]; + +pub static BOOK_DETAILS_BLOCKS: [ActiveReadarrBlock; 16] = [ + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::BookHistoryDetails, + ActiveReadarrBlock::BookHistorySortPrompt, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + ActiveReadarrBlock::DeleteBookFilePrompt, + ActiveReadarrBlock::FilterBookHistory, + ActiveReadarrBlock::FilterBookHistoryError, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + ActiveReadarrBlock::ManualBookSearchSortPrompt, + ActiveReadarrBlock::SearchBookHistory, + ActiveReadarrBlock::SearchBookHistoryError, + ActiveReadarrBlock::SearchEditions, + ActiveReadarrBlock::SearchEditionsError, +]; + +pub static EDITION_DETAILS_BLOCKS: [ActiveReadarrBlock; 1] = + [ActiveReadarrBlock::BookEditionDetails]; + +pub static ADD_AUTHOR_BLOCKS: [ActiveReadarrBlock; 12] = [ + ActiveReadarrBlock::AddAuthorAlreadyInLibrary, + ActiveReadarrBlock::AddAuthorConfirmPrompt, + ActiveReadarrBlock::AddAuthorEmptySearchResults, + ActiveReadarrBlock::AddAuthorPrompt, + ActiveReadarrBlock::AddAuthorSearchInput, + ActiveReadarrBlock::AddAuthorSearchResults, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + ActiveReadarrBlock::AddAuthorSelectMonitor, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + ActiveReadarrBlock::AddAuthorTagsInput, +]; + +pub const ADD_AUTHOR_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::AddAuthorSelectRootFolder], + &[ActiveReadarrBlock::AddAuthorSelectMonitor], + &[ActiveReadarrBlock::AddAuthorSelectMonitorNewItems], + &[ActiveReadarrBlock::AddAuthorSelectQualityProfile], + &[ActiveReadarrBlock::AddAuthorSelectMetadataProfile], + &[ActiveReadarrBlock::AddAuthorTagsInput], + &[ActiveReadarrBlock::AddAuthorConfirmPrompt], +]; + +pub static DELETE_AUTHOR_BLOCKS: [ActiveReadarrBlock; 4] = [ + ActiveReadarrBlock::DeleteAuthorPrompt, + ActiveReadarrBlock::DeleteAuthorConfirmPrompt, + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile, + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion, +]; + +pub const DELETE_AUTHOR_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::DeleteAuthorToggleDeleteFile], + &[ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion], + &[ActiveReadarrBlock::DeleteAuthorConfirmPrompt], +]; + +pub static DELETE_BOOK_BLOCKS: [ActiveReadarrBlock; 4] = [ + ActiveReadarrBlock::DeleteBookPrompt, + ActiveReadarrBlock::DeleteBookConfirmPrompt, + ActiveReadarrBlock::DeleteBookToggleDeleteFile, + ActiveReadarrBlock::DeleteBookToggleAddListExclusion, +]; + +pub const DELETE_BOOK_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::DeleteBookToggleDeleteFile], + &[ActiveReadarrBlock::DeleteBookToggleAddListExclusion], + &[ActiveReadarrBlock::DeleteBookConfirmPrompt], +]; + +pub static EDIT_AUTHOR_BLOCKS: [ActiveReadarrBlock; 8] = [ + ActiveReadarrBlock::EditAuthorPrompt, + ActiveReadarrBlock::EditAuthorConfirmPrompt, + ActiveReadarrBlock::EditAuthorPathInput, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + ActiveReadarrBlock::EditAuthorTagsInput, + ActiveReadarrBlock::EditAuthorToggleMonitored, +]; + +pub const EDIT_AUTHOR_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::EditAuthorToggleMonitored], + &[ActiveReadarrBlock::EditAuthorSelectMonitorNewItems], + &[ActiveReadarrBlock::EditAuthorSelectQualityProfile], + &[ActiveReadarrBlock::EditAuthorSelectMetadataProfile], + &[ActiveReadarrBlock::EditAuthorPathInput], + &[ActiveReadarrBlock::EditAuthorTagsInput], + &[ActiveReadarrBlock::EditAuthorConfirmPrompt], +]; + +pub static ADD_ROOT_FOLDER_BLOCKS: [ActiveReadarrBlock; 9] = [ + ActiveReadarrBlock::AddRootFolderPrompt, + ActiveReadarrBlock::AddRootFolderConfirmPrompt, + ActiveReadarrBlock::AddRootFolderNameInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + ActiveReadarrBlock::AddRootFolderTagsInput, +]; + +pub const ADD_ROOT_FOLDER_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::AddRootFolderNameInput], + &[ActiveReadarrBlock::AddRootFolderPathInput], + &[ActiveReadarrBlock::AddRootFolderSelectMonitor], + &[ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems], + &[ActiveReadarrBlock::AddRootFolderSelectQualityProfile], + &[ActiveReadarrBlock::AddRootFolderSelectMetadataProfile], + &[ActiveReadarrBlock::AddRootFolderTagsInput], + &[ActiveReadarrBlock::AddRootFolderConfirmPrompt], +]; + +pub static ROOT_FOLDERS_BLOCKS: [ActiveReadarrBlock; 2] = [ + ActiveReadarrBlock::RootFolders, + ActiveReadarrBlock::DeleteRootFolderPrompt, +]; + +pub static BLOCKLIST_BLOCKS: [ActiveReadarrBlock; 5] = [ + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistItemDetails, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ActiveReadarrBlock::BlocklistSortPrompt, +]; + +pub static DOWNLOADS_BLOCKS: [ActiveReadarrBlock; 3] = [ + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ActiveReadarrBlock::UpdateDownloadsPrompt, +]; + +pub static HISTORY_BLOCKS: [ActiveReadarrBlock; 7] = [ + ActiveReadarrBlock::History, + ActiveReadarrBlock::HistoryItemDetails, + ActiveReadarrBlock::HistorySortPrompt, + ActiveReadarrBlock::SearchHistory, + ActiveReadarrBlock::SearchHistoryError, + ActiveReadarrBlock::FilterHistory, + ActiveReadarrBlock::FilterHistoryError, +]; + +pub static EDIT_INDEXER_BLOCKS: [ActiveReadarrBlock; 11] = [ + ActiveReadarrBlock::EditIndexerPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerPriorityInput, + ActiveReadarrBlock::EditIndexerUrlInput, + ActiveReadarrBlock::EditIndexerTagsInput, +]; + +pub const EDIT_INDEXER_TORRENT_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerTagsInput, + ], + &[ + ActiveReadarrBlock::EditIndexerPriorityInput, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ], + &[ + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ], +]; + +pub const EDIT_INDEXER_NZB_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerTagsInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerPriorityInput, + ], + &[ + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ], +]; + +pub static INDEXER_SETTINGS_BLOCKS: [ActiveReadarrBlock; 6] = [ + ActiveReadarrBlock::AllIndexerSettingsPrompt, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, +]; + +pub const INDEXER_SETTINGS_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::IndexerSettingsMinimumAgeInput], + &[ActiveReadarrBlock::IndexerSettingsRetentionInput], + &[ActiveReadarrBlock::IndexerSettingsMaximumSizeInput], + &[ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput], + &[ActiveReadarrBlock::IndexerSettingsConfirmPrompt], +]; + +pub static INDEXERS_BLOCKS: [ActiveReadarrBlock; 3] = [ + ActiveReadarrBlock::Indexers, + ActiveReadarrBlock::DeleteIndexerPrompt, + ActiveReadarrBlock::TestIndexer, +]; + +pub static SYSTEM_DETAILS_BLOCKS: [ActiveReadarrBlock; 5] = [ + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates, +]; + +impl From for Route { + fn from(active_readarr_block: ActiveReadarrBlock) -> Route { + Route::Readarr(active_readarr_block, None) + } +} + +impl From<(ActiveReadarrBlock, Option)> for Route { + fn from(value: (ActiveReadarrBlock, Option)) -> Route { + Route::Readarr(value.0, value.1) + } +} diff --git a/src/models/servarr_data/readarr/readarr_data_tests.rs b/src/models/servarr_data/readarr/readarr_data_tests.rs new file mode 100644 index 00000000..548059f8 --- /dev/null +++ b/src/models/servarr_data/readarr/readarr_data_tests.rs @@ -0,0 +1,789 @@ +#[cfg(test)] +mod tests { + use crate::app::context_clues::{ + BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, + INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, + }; + use crate::app::readarr::readarr_context_clues::{ + AUTHOR_DETAILS_CONTEXT_CLUES, AUTHOR_HISTORY_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES, + MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, + }; + use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_AUTHOR_SELECTION_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, + ADD_ROOT_FOLDER_SELECTION_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BLOCKLIST_BLOCKS, + BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_AUTHOR_SELECTION_BLOCKS, DELETE_BOOK_BLOCKS, + DELETE_BOOK_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_AUTHOR_BLOCKS, + EDIT_AUTHOR_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_INDEXER_NZB_SELECTION_BLOCKS, + EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDITION_DETAILS_BLOCKS, HISTORY_BLOCKS, + INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, LIBRARY_BLOCKS, + ROOT_FOLDERS_BLOCKS, ReadarrData, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::{BlockSelectionState, Route}; + use bimap::BiMap; + use chrono::{DateTime, Utc}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::Number; + + #[test] + fn test_from_active_readarr_block_to_route() { + assert_eq!( + Route::from(ActiveReadarrBlock::Authors), + Route::Readarr(ActiveReadarrBlock::Authors, None) + ); + } + + #[test] + fn test_from_tuple_to_route_with_context() { + assert_eq!( + Route::from(( + ActiveReadarrBlock::Authors, + Some(ActiveReadarrBlock::Authors) + )), + Route::Readarr( + ActiveReadarrBlock::Authors, + Some(ActiveReadarrBlock::Authors), + ) + ); + } + + #[test] + fn test_reset_delete_preferences() { + let mut readarr_data = ReadarrData { + delete_files: true, + add_import_list_exclusion: true, + ..ReadarrData::default() + }; + + readarr_data.reset_delete_preferences(); + + assert!(!readarr_data.delete_files); + assert!(!readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_reset_author_info_tabs() { + let mut readarr_data = ReadarrData::default(); + readarr_data.books.set_items(vec![Book::default()]); + readarr_data + .author_releases + .set_items(vec![ReadarrRelease::default()]); + readarr_data + .author_history + .set_items(vec![ReadarrHistoryItem::default()]); + readarr_data.author_info_tabs.index = 1; + + readarr_data.reset_author_info_tabs(); + + assert_is_empty!(readarr_data.books); + assert_is_empty!(readarr_data.author_releases); + assert_is_empty!(readarr_data.author_history); + assert_eq!(readarr_data.author_info_tabs.index, 0); + } + + #[test] + fn test_tag_ids_to_display() { + let mut tags_map = BiMap::new(); + tags_map.insert(3, "test 3".to_owned()); + tags_map.insert(2, "test 2".to_owned()); + tags_map.insert(1, "test 1".to_owned()); + let readarr_data = ReadarrData { + tags_map, + ..ReadarrData::default() + }; + + assert_str_eq!( + readarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), + "test 1, test 2" + ); + } + + #[test] + fn test_sorted_quality_profile_names() { + let mut quality_profile_map = BiMap::new(); + quality_profile_map.insert(3, "test 1".to_owned()); + quality_profile_map.insert(2, "test 2".to_owned()); + quality_profile_map.insert(1, "test 3".to_owned()); + let readarr_data = ReadarrData { + quality_profile_map, + ..ReadarrData::default() + }; + let expected_quality_profile_vec = vec![ + "test 3".to_owned(), + "test 2".to_owned(), + "test 1".to_owned(), + ]; + + assert_iter_eq!( + readarr_data.sorted_quality_profile_names(), + expected_quality_profile_vec + ); + } + + #[test] + fn test_sorted_metadata_profile_names() { + let mut metadata_profile_map = BiMap::new(); + metadata_profile_map.insert(3, "test 1".to_owned()); + metadata_profile_map.insert(2, "test 2".to_owned()); + metadata_profile_map.insert(1, "test 3".to_owned()); + let readarr_data = ReadarrData { + metadata_profile_map, + ..ReadarrData::default() + }; + let expected_metadata_profile_vec = vec![ + "test 3".to_owned(), + "test 2".to_owned(), + "test 1".to_owned(), + ]; + + assert_iter_eq!( + readarr_data.sorted_metadata_profile_names(), + expected_metadata_profile_vec + ); + } + + #[test] + fn test_readarr_data_default() { + let readarr_data = ReadarrData::default(); + + assert_none!(readarr_data.add_author_modal); + assert_none!(readarr_data.add_author_search); + assert!(!readarr_data.add_import_list_exclusion); + assert_none!(readarr_data.add_root_folder_modal); + assert_none!(readarr_data.add_searched_authors); + assert_is_empty!(readarr_data.author_history); + assert_is_empty!(readarr_data.author_releases); + assert_is_empty!(readarr_data.authors); + assert_is_empty!(readarr_data.blocklist); + assert_none!(readarr_data.book_details_modal); + assert_is_empty!(readarr_data.books); + assert!(!readarr_data.delete_files); + assert_is_empty!(readarr_data.disk_space_vec); + assert_is_empty!(readarr_data.downloads); + assert_none!(readarr_data.edit_author_modal); + assert_none!(readarr_data.edit_indexer_modal); + assert_is_empty!(readarr_data.history); + assert_none!(readarr_data.indexer_settings); + assert_none!(readarr_data.indexer_test_all_results); + assert_none!(readarr_data.indexer_test_errors); + assert_is_empty!(readarr_data.indexers); + assert_is_empty!(readarr_data.log_details); + assert_is_empty!(readarr_data.logs); + assert_is_empty!(readarr_data.metadata_profile_map); + assert!(!readarr_data.prompt_confirm); + assert_none!(readarr_data.prompt_confirm_action); + assert_is_empty!(readarr_data.quality_profile_map); + assert_is_empty!(readarr_data.queued_events); + assert_is_empty!(readarr_data.root_folders); + assert_eq!(readarr_data.selected_block, BlockSelectionState::default()); + assert_eq!(readarr_data.start_time, >::default()); + assert_is_empty!(readarr_data.tags_map); + assert_is_empty!(readarr_data.tasks); + assert_is_empty!(readarr_data.updates); + assert_is_empty!(readarr_data.version); + + assert_eq!(readarr_data.main_tabs.tabs.len(), 7); + + assert_str_eq!(readarr_data.main_tabs.tabs[0].title, "Library"); + assert_eq!( + readarr_data.main_tabs.tabs[0].route, + ActiveReadarrBlock::Authors.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[0].contextual_help, + &AUTHORS_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[0].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[1].title, "Downloads"); + assert_eq!( + readarr_data.main_tabs.tabs[1].route, + ActiveReadarrBlock::Downloads.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[1].contextual_help, + &DOWNLOADS_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[1].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[2].title, "Blocklist"); + assert_eq!( + readarr_data.main_tabs.tabs[2].route, + ActiveReadarrBlock::Blocklist.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[2].contextual_help, + &BLOCKLIST_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[2].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[3].title, "History"); + assert_eq!( + readarr_data.main_tabs.tabs[3].route, + ActiveReadarrBlock::History.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[3].contextual_help, + &HISTORY_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[3].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[4].title, "Root Folders"); + assert_eq!( + readarr_data.main_tabs.tabs[4].route, + ActiveReadarrBlock::RootFolders.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[4].contextual_help, + &ROOT_FOLDERS_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[4].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[5].title, "Indexers"); + assert_eq!( + readarr_data.main_tabs.tabs[5].route, + ActiveReadarrBlock::Indexers.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[5].contextual_help, + &INDEXERS_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[5].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[6].title, "System"); + assert_eq!( + readarr_data.main_tabs.tabs[6].route, + ActiveReadarrBlock::System.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[6].contextual_help, + &SYSTEM_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[6].config); + + assert_eq!(readarr_data.author_info_tabs.tabs.len(), 3); + + assert_str_eq!(readarr_data.author_info_tabs.tabs[0].title, "Books"); + assert_eq!( + readarr_data.author_info_tabs.tabs[0].route, + ActiveReadarrBlock::AuthorDetails.into() + ); + assert_some_eq_x!( + &readarr_data.author_info_tabs.tabs[0].contextual_help, + &AUTHOR_DETAILS_CONTEXT_CLUES + ); + assert_none!(readarr_data.author_info_tabs.tabs[0].config); + + assert_str_eq!(readarr_data.author_info_tabs.tabs[1].title, "History"); + assert_eq!( + readarr_data.author_info_tabs.tabs[1].route, + ActiveReadarrBlock::AuthorHistory.into() + ); + assert_some_eq_x!( + &readarr_data.author_info_tabs.tabs[1].contextual_help, + &AUTHOR_HISTORY_CONTEXT_CLUES + ); + assert_none!(readarr_data.author_info_tabs.tabs[1].config); + + assert_str_eq!(readarr_data.author_info_tabs.tabs[2].title, "Author Search"); + assert_eq!( + readarr_data.author_info_tabs.tabs[2].route, + ActiveReadarrBlock::ManualAuthorSearch.into() + ); + assert_some_eq_x!( + &readarr_data.author_info_tabs.tabs[2].contextual_help, + &MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES + ); + assert_none!(readarr_data.author_info_tabs.tabs[2].config); + } + + #[test] + fn test_library_blocks_contains_expected_blocks() { + assert_eq!(LIBRARY_BLOCKS.len(), 7); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::Authors)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::AuthorsSortPrompt)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::FilterAuthors)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::FilterAuthorsError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::SearchAuthors)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::SearchAuthorsError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::UpdateAllAuthorsPrompt)); + } + + #[test] + fn test_author_details_blocks_contains_expected_blocks() { + assert_eq!(AUTHOR_DETAILS_BLOCKS.len(), 15); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorDetails)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorHistory)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorHistoryDetails)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorHistorySortPrompt)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AutomaticallySearchAuthorPrompt)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::FilterAuthorHistory)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::FilterAuthorHistoryError)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualAuthorSearch)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualAuthorSearchSortPrompt)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchBooks)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchBooksError)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchAuthorHistory)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchAuthorHistoryError)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::UpdateAndScanAuthorPrompt)); + } + + #[test] + fn test_book_details_blocks_contents() { + assert_eq!(BOOK_DETAILS_BLOCKS.len(), 16); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookDetails)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookHistory)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookHistoryDetails)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookHistorySortPrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AutomaticallySearchBookPrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookFilePrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::FilterBookHistory)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::FilterBookHistoryError)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookFileInfo)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualBookSearch)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualBookSearchConfirmPrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualBookSearchSortPrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchBookHistory)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchBookHistoryError)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchEditions)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchEditionsError)); + } + + #[test] + fn test_book_details_blocks_excludes_book_edition_details() { + assert!(!BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookEditionDetails)); + } + + #[test] + fn test_edition_details_blocks_contents() { + assert_eq!(EDITION_DETAILS_BLOCKS.len(), 1); + assert!(EDITION_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookEditionDetails)); + } + + #[test] + fn test_blocklist_blocks_contents() { + assert_eq!(BLOCKLIST_BLOCKS.len(), 5); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::Blocklist)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::BlocklistItemDetails)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::DeleteBlocklistItemPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::BlocklistClearAllItemsPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::BlocklistSortPrompt)); + } + + #[test] + fn test_downloads_blocks_contains_expected_blocks() { + assert_eq!(DOWNLOADS_BLOCKS.len(), 3); + assert!(DOWNLOADS_BLOCKS.contains(&ActiveReadarrBlock::Downloads)); + assert!(DOWNLOADS_BLOCKS.contains(&ActiveReadarrBlock::DeleteDownloadPrompt)); + assert!(DOWNLOADS_BLOCKS.contains(&ActiveReadarrBlock::UpdateDownloadsPrompt)); + } + + #[test] + fn test_history_blocks_contains_expected_blocks() { + assert_eq!(HISTORY_BLOCKS.len(), 7); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::History)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::HistoryItemDetails)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::HistorySortPrompt)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::SearchHistory)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::SearchHistoryError)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::FilterHistory)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::FilterHistoryError)); + } + + #[test] + fn test_add_author_blocks_contents() { + assert_eq!(ADD_AUTHOR_BLOCKS.len(), 12); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorAlreadyInLibrary)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorConfirmPrompt)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorEmptySearchResults)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorPrompt)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSearchInput)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSearchResults)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectMetadataProfile)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectMonitor)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectMonitorNewItems)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectQualityProfile)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectRootFolder)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorTagsInput)); + } + + #[test] + fn test_add_author_selection_blocks_ordering() { + let mut add_author_block_iter = ADD_AUTHOR_SELECTION_BLOCKS.iter(); + + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectRootFolder] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectMonitor] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectMonitorNewItems] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectQualityProfile] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectMetadataProfile] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorTagsInput] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorConfirmPrompt] + ); + assert_none!(add_author_block_iter.next()); + } + + #[test] + fn test_delete_author_blocks_contents() { + assert_eq!(DELETE_AUTHOR_BLOCKS.len(), 4); + assert!(DELETE_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::DeleteAuthorPrompt)); + assert!(DELETE_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::DeleteAuthorConfirmPrompt)); + assert!(DELETE_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::DeleteAuthorToggleDeleteFile)); + assert!(DELETE_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion)); + } + + #[test] + fn test_delete_author_selection_blocks_ordering() { + let mut delete_author_block_iter = DELETE_AUTHOR_SELECTION_BLOCKS.iter(); + + assert_eq!( + delete_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteAuthorToggleDeleteFile] + ); + assert_eq!( + delete_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion] + ); + assert_eq!( + delete_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteAuthorConfirmPrompt] + ); + assert_none!(delete_author_block_iter.next()); + } + + #[test] + fn test_delete_book_blocks_contents() { + assert_eq!(DELETE_BOOK_BLOCKS.len(), 4); + assert!(DELETE_BOOK_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookPrompt)); + assert!(DELETE_BOOK_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookConfirmPrompt)); + assert!(DELETE_BOOK_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookToggleDeleteFile)); + assert!(DELETE_BOOK_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookToggleAddListExclusion)); + } + + #[test] + fn test_delete_book_selection_blocks_ordering() { + let mut delete_book_block_iter = DELETE_BOOK_SELECTION_BLOCKS.iter(); + + assert_eq!( + delete_book_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteBookToggleDeleteFile] + ); + assert_eq!( + delete_book_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteBookToggleAddListExclusion] + ); + assert_eq!( + delete_book_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteBookConfirmPrompt] + ); + assert_none!(delete_book_block_iter.next()); + } + + #[test] + fn test_edit_author_blocks() { + assert_eq!(EDIT_AUTHOR_BLOCKS.len(), 8); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorPrompt)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorConfirmPrompt)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorPathInput)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorSelectMetadataProfile)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorSelectMonitorNewItems)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorSelectQualityProfile)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorTagsInput)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorToggleMonitored)); + } + + #[test] + fn test_edit_author_selection_blocks_ordering() { + let mut edit_author_block_iter = EDIT_AUTHOR_SELECTION_BLOCKS.iter(); + + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorToggleMonitored] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorSelectMonitorNewItems] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorSelectQualityProfile] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorSelectMetadataProfile] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorPathInput] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorTagsInput] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorConfirmPrompt] + ); + assert_none!(edit_author_block_iter.next()); + } + + #[test] + fn test_root_folders_blocks_contents() { + assert_eq!(ROOT_FOLDERS_BLOCKS.len(), 2); + assert!(ROOT_FOLDERS_BLOCKS.contains(&ActiveReadarrBlock::RootFolders)); + assert!(ROOT_FOLDERS_BLOCKS.contains(&ActiveReadarrBlock::DeleteRootFolderPrompt)); + } + + #[test] + fn test_add_root_folder_blocks_contents() { + assert_eq!(ADD_ROOT_FOLDER_BLOCKS.len(), 9); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderPrompt)); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderConfirmPrompt)); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderNameInput)); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderPathInput)); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderSelectMonitor)); + assert!( + ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems) + ); + assert!( + ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderSelectQualityProfile) + ); + assert!( + ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderSelectMetadataProfile) + ); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderTagsInput)); + } + + #[test] + fn test_add_root_folder_selection_blocks_ordering() { + let mut add_root_folder_block_iter = ADD_ROOT_FOLDER_SELECTION_BLOCKS.iter(); + + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderNameInput] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderPathInput] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderSelectMonitor] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderSelectQualityProfile] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderSelectMetadataProfile] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderTagsInput] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderConfirmPrompt] + ); + assert_none!(add_root_folder_block_iter.next()); + } + + #[test] + fn test_edit_indexer_blocks_contents() { + assert_eq!(EDIT_INDEXER_BLOCKS.len(), 11); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerPrompt)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerConfirmPrompt)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerApiKeyInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerNameInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerSeedRatioInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerToggleEnableRss)); + assert!( + EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch) + ); + assert!( + EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch) + ); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerPriorityInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerUrlInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerTagsInput)); + } + + #[test] + fn test_edit_indexer_torrent_selection_blocks_ordering() { + let mut edit_indexer_block_iter = EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.iter(); + + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerSeedRatioInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerTagsInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerPriorityInput, + ActiveReadarrBlock::EditIndexerConfirmPrompt + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt + ] + ); + assert_none!(edit_indexer_block_iter.next()); + } + + #[test] + fn test_edit_indexer_nzb_selection_blocks_ordering() { + let mut edit_indexer_block_iter = EDIT_INDEXER_NZB_SELECTION_BLOCKS.iter(); + + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerTagsInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerPriorityInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt + ] + ); + assert_none!(edit_indexer_block_iter.next()); + } + + #[test] + fn test_indexer_settings_blocks_contents() { + assert_eq!(INDEXER_SETTINGS_BLOCKS.len(), 6); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::AllIndexerSettingsPrompt)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::IndexerSettingsConfirmPrompt)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::IndexerSettingsMaximumSizeInput)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::IndexerSettingsMinimumAgeInput)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::IndexerSettingsRetentionInput)); + assert!( + INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::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(), + &[ActiveReadarrBlock::IndexerSettingsMinimumAgeInput] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveReadarrBlock::IndexerSettingsRetentionInput] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveReadarrBlock::IndexerSettingsMaximumSizeInput] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveReadarrBlock::IndexerSettingsConfirmPrompt] + ); + assert_none!(indexer_settings_block_iter.next()); + } + + #[test] + fn test_indexers_blocks_contents() { + assert_eq!(INDEXERS_BLOCKS.len(), 3); + assert!(INDEXERS_BLOCKS.contains(&ActiveReadarrBlock::Indexers)); + assert!(INDEXERS_BLOCKS.contains(&ActiveReadarrBlock::DeleteIndexerPrompt)); + assert!(INDEXERS_BLOCKS.contains(&ActiveReadarrBlock::TestIndexer)); + } + + #[test] + fn test_indexers_blocks_excludes_test_all_indexers() { + assert!(!INDEXERS_BLOCKS.contains(&ActiveReadarrBlock::TestAllIndexers)); + } + + #[test] + fn test_system_details_blocks_contents() { + assert_eq!(SYSTEM_DETAILS_BLOCKS.len(), 5); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemLogs)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemQueuedEvents)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemTasks)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemTaskStartConfirmPrompt)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemUpdates)); + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs index f74ba3b9..8cf353ca 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -19,12 +19,14 @@ use tokio_util::sync::CancellationToken; use crate::app::{App, ServarrConfig}; use crate::models::Serdeable; use crate::network::radarr_network::RadarrEvent; +use crate::network::readarr_network::ReadarrEvent; #[cfg(test)] use mockall::automock; use reqwest::header::HeaderMap; pub mod lidarr_network; pub mod radarr_network; +pub mod readarr_network; pub mod sonarr_network; mod utils; @@ -50,6 +52,7 @@ pub enum NetworkEvent { Radarr(RadarrEvent), Sonarr(SonarrEvent), Lidarr(LidarrEvent), + Readarr(ReadarrEvent), } #[derive(Clone)] @@ -75,6 +78,10 @@ impl NetworkTrait for Network<'_, '_> { .handle_lidarr_event(lidarr_event) .await .map(Serdeable::from), + NetworkEvent::Readarr(readarr_event) => self + .handle_readarr_event(readarr_event) + .await + .map(Serdeable::from), }; let mut app = self.app.lock().await; @@ -242,6 +249,7 @@ impl<'a, 'b> Network<'a, 'b> { NetworkEvent::Radarr(_) => (7878, "v3"), NetworkEvent::Sonarr(_) => (8989, "v3"), NetworkEvent::Lidarr(_) => (8686, "v1"), + NetworkEvent::Readarr(_) => (8787, "v1"), }; let mut uri = if let Some(servarr_uri) = uri { format!("{servarr_uri}/api/{api_version}{resource}") diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 2b14efc7..7672144d 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -944,6 +944,7 @@ pub(in crate::network) mod test_utils { let network_event_clone: NetworkEvent = network_event.clone().into(); let api_version = match &network_event_clone { NetworkEvent::Lidarr(_) => "v1", + NetworkEvent::Readarr(_) => "v1", _ => "v3", }; let mut server = Server::new_async().await; diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs new file mode 100644 index 00000000..747f5a65 --- /dev/null +++ b/src/network/readarr_network/mod.rs @@ -0,0 +1,29 @@ +use anyhow::Result; + +use super::{NetworkEvent, NetworkResource}; +use crate::models::readarr_models::ReadarrSerdeable; +use crate::network::Network; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum ReadarrEvent {} + +impl NetworkResource for ReadarrEvent { + fn resource(&self) -> &'static str { + match *self {} + } +} + +impl From for NetworkEvent { + fn from(readarr_event: ReadarrEvent) -> Self { + NetworkEvent::Readarr(readarr_event) + } +} + +impl Network<'_, '_> { + pub async fn handle_readarr_event( + &mut self, + readarr_event: ReadarrEvent, + ) -> Result { + match readarr_event {} + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 17bc5a77..db2c3a93 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -10,6 +10,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Tabs; use ratatui::widgets::Wrap; use ratatui::widgets::{Clear, Row}; +use readarr_ui::ReadarrUi; use sonarr_ui::SonarrUi; use utils::layout_block; @@ -32,6 +33,7 @@ use crate::ui::widgets::popup::{Popup, Size}; mod builtin_themes; mod lidarr_ui; mod radarr_ui; +mod readarr_ui; mod sonarr_ui; mod styles; pub mod theme; @@ -94,6 +96,10 @@ pub fn ui(f: &mut Frame<'_>, app: &mut App<'_>) { LidarrUi::draw_context_row(f, app, context_area); LidarrUi::draw(f, app, table_area); } + route if ReadarrUi::accepts(route) => { + ReadarrUi::draw_context_row(f, app, context_area); + ReadarrUi::draw(f, app, table_area); + } _ => (), } diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs new file mode 100644 index 00000000..65f7e839 --- /dev/null +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -0,0 +1,235 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; + use crate::ui::styles::ManagarrStyle; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + + #[test] + fn test_library_ui_accepts() { + for active_readarr_block in ActiveReadarrBlock::iter() { + if LIBRARY_BLOCKS.contains(&active_readarr_block) { + assert!( + LibraryUi::accepts(active_readarr_block.into()), + "{active_readarr_block} is not accepted by the LibraryUi" + ); + } else { + assert!( + !LibraryUi::accepts(active_readarr_block.into()), + "{active_readarr_block} should not be accepted by LibraryUi" + ); + } + } + } + + #[test] + fn test_decorate_row_with_style_unmonitored() { + let author = Author::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_row_with_style_downloaded_when_ended_and_all_books_present() { + let author = Author { + monitored: true, + status: AuthorStatus::Ended, + statistics: Some(AuthorStatistics { + book_file_count: 10, + total_book_count: 10, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_row_with_style_missing_when_ended_and_books_are_missing() { + let author = Author { + monitored: true, + status: AuthorStatus::Ended, + statistics: Some(AuthorStatistics { + book_file_count: 5, + total_book_count: 10, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_ended_and_no_statistics() { + let author = Author { + monitored: true, + status: AuthorStatus::Ended, + statistics: None, + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_ended_and_total_book_count_is_zero() { + let author = Author { + monitored: true, + status: AuthorStatus::Ended, + statistics: Some(AuthorStatistics { + book_file_count: 0, + total_book_count: 0, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_unreleased_when_continuing_and_all_books_present() { + let author = Author { + monitored: true, + status: AuthorStatus::Continuing, + statistics: Some(AuthorStatistics { + book_file_count: 10, + total_book_count: 10, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_row_with_style_missing_when_continuing_and_books_are_missing() { + let author = Author { + monitored: true, + status: AuthorStatus::Continuing, + statistics: Some(AuthorStatistics { + book_file_count: 5, + total_book_count: 10, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_continuing_and_no_statistics() { + let author = Author { + monitored: true, + status: AuthorStatus::Continuing, + statistics: None, + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + #[test] + fn test_decorate_row_with_style_defaults_to_indeterminate_for_deleted_status() { + let author = Author { + monitored: true, + status: AuthorStatus::Deleted, + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + mod snapshot_tests { + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use rstest::rstest; + + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::LibraryUi; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + #[rstest] + fn test_library_ui_renders( + #[values( + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::AuthorsSortPrompt, + ActiveReadarrBlock::SearchAuthors, + ActiveReadarrBlock::SearchAuthorsError, + ActiveReadarrBlock::FilterAuthors, + ActiveReadarrBlock::FilterAuthorsError + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("readarr_library_{active_readarr_block}"), output); + } + + #[test] + fn test_library_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_library_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.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/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs new file mode 100644 index 00000000..1e1730f4 --- /dev/null +++ b/src/ui/readarr_ui/library/mod.rs @@ -0,0 +1,176 @@ +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + widgets::{Cell, Row}, +}; + +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::utils::convert_to_gb; +use crate::{ + app::App, + models::{ + Route, + readarr_models::{Author, AuthorStatus}, + servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS}, + }, + ui::{ + DrawUi, + styles::ManagarrStyle, + utils::{get_width_from_percentage, layout_block_top_border}, + }, +}; + +#[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::Readarr(active_readarr_block, _) = route { + return LIBRARY_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_library(f, app, area); + } +} + +fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let current_selection = if !app.data.readarr_data.authors.items.is_empty() { + app.data.readarr_data.authors.current_selection().clone() + } else { + Author::default() + }; + let quality_profile_map = &app.data.readarr_data.quality_profile_map; + let metadata_profile_map = &app.data.readarr_data.metadata_profile_map; + let tags_map = &app.data.readarr_data.tags_map; + let content = Some(&mut app.data.readarr_data.authors); + + let authors_table_row_mapping = |author: &Author| { + author.author_name.scroll_left_or_reset( + get_width_from_percentage(area, 25), + *author == current_selection, + app.should_text_scroll, + ); + let monitored = if author.monitored { "🏷" } else { "" }; + let author_type = author.author_type.clone().unwrap_or_default(); + let size = author + .statistics + .as_ref() + .map_or(0f64, |stats| convert_to_gb(stats.size_on_disk)); + let quality_profile = quality_profile_map + .get_by_left(&author.quality_profile_id) + .cloned() + .unwrap_or_default(); + let metadata_profile = metadata_profile_map + .get_by_left(&author.metadata_profile_id) + .cloned() + .unwrap_or_default(); + let books = author + .statistics + .as_ref() + .map_or(0, |stats| stats.book_count); + let tags = author + .tags + .iter() + .filter_map(|tag_id| { + let id = tag_id.as_i64()?; + tags_map.get_by_left(&id).cloned() + }) + .collect::>() + .join(", "); + + decorate_author_row_with_style( + author, + Row::new(vec![ + Cell::from(author.author_name.to_string()), + Cell::from(author_type), + Cell::from(author.status.to_display_str()), + Cell::from(quality_profile), + Cell::from(metadata_profile), + Cell::from(books.to_string()), + Cell::from(format!("{size:.2} GB")), + Cell::from(monitored.to_owned()), + Cell::from(tags), + ]), + ) + }; + let authors_table = ManagarrTable::new(content, authors_table_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::AuthorsSortPrompt) + .searching(active_readarr_block == ActiveReadarrBlock::SearchAuthors) + .filtering(active_readarr_block == ActiveReadarrBlock::FilterAuthors) + .search_produced_empty_results(active_readarr_block == ActiveReadarrBlock::SearchAuthorsError) + .filter_produced_empty_results(active_readarr_block == ActiveReadarrBlock::FilterAuthorsError) + .headers([ + "Name", + "Type", + "Status", + "Quality Profile", + "Metadata Profile", + "Books", + "Size", + "Monitored", + "Tags", + ]) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(9), + Constraint::Percentage(8), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(7), + Constraint::Percentage(8), + Constraint::Percentage(6), + Constraint::Percentage(11), + ]); + + if [ + ActiveReadarrBlock::SearchAuthors, + ActiveReadarrBlock::FilterAuthors, + ] + .contains(&active_readarr_block) + { + authors_table.show_cursor(f, area); + } + + f.render_widget(authors_table, area); + } +} + +fn decorate_author_row_with_style<'a>(author: &Author, row: Row<'a>) -> Row<'a> { + if !author.monitored { + return row.unmonitored(); + } + + match author.status { + AuthorStatus::Ended => { + if let Some(ref stats) = author.statistics { + return if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 { + row.downloaded() + } else { + row.missing() + }; + } + row.indeterminate() + } + AuthorStatus::Continuing => { + if let Some(ref stats) = author.statistics { + return if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 { + row.unreleased() + } else { + row.missing() + }; + } + row.indeterminate() + } + _ => row.indeterminate(), + } +} diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap new file mode 100644 index 00000000..fead8278 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap new file mode 100644 index 00000000..90000a03 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap new file mode 100644 index 00000000..75b980f2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap new file mode 100644 index 00000000..4a9fb66d --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name Type Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + + + + + + + + + + + ╭───────────────────────────────╮ + │Name │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap new file mode 100644 index 00000000..e45676e5 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + + + + + + + + + + + + + + + + + + + ╭───────────────── Filter ──────────────────╮ + │author filter │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap new file mode 100644 index 00000000..7380fa4f --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │The given filter produced empty results│ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap new file mode 100644 index 00000000..8fa53ca2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + + + + + + + + + + + + + + + + + + + ╭───────────────── Search ──────────────────╮ + │author search │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap new file mode 100644 index 00000000..7a519870 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │ No items found matching search │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs new file mode 100644 index 00000000..643b9cce --- /dev/null +++ b/src/ui/readarr_ui/mod.rs @@ -0,0 +1,217 @@ +#[cfg(test)] +use crate::ui::ui_test_utils::test_utils::Utc; +use chrono::Duration; +#[cfg(not(test))] +use chrono::Utc; +use library::LibraryUi; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Stylize, + text::Text, + widgets::Paragraph, +}; +use std::{cmp, iter}; + +use super::{ + DrawUi, draw_tabs, + styles::ManagarrStyle, + utils::{ + borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block, + }, + widgets::loading_block::LoadingBlock, +}; +use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; +use crate::{ + app::App, + logos::READARR_LOGO, + models::{ + Route, + readarr_models::DownloadRecord, + servarr_data::readarr::readarr_data::ReadarrData, + servarr_models::{DiskSpace, RootFolder}, + }, + utils::convert_to_gb, +}; + +mod library; + +#[cfg(test)] +#[path = "readarr_ui_tests.rs"] +mod readarr_ui_tests; + +pub(super) struct ReadarrUi; + +impl DrawUi for ReadarrUi { + fn accepts(route: Route) -> bool { + matches!(route, Route::Readarr(_, _)) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let content_area = draw_tabs(f, area, "Authors", &app.data.readarr_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_readarr_logo(f, logo_area); + } +} + +fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Stats"); + + if !app.data.readarr_data.version.is_empty() { + f.render_widget(block, area); + let ReadarrData { + root_folders, + disk_space_vec, + start_time, + .. + } = &app.data.readarr_data; + let monitored_disk_space_vec = extract_monitored_disk_space_vec(app, disk_space_vec.clone()); + let monitored_root_folders = extract_monitored_root_folders(app, root_folders.items.clone()); + + let mut constraints = vec![ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]; + + constraints.append( + &mut iter::repeat_n( + Constraint::Length(1), + monitored_disk_space_vec.len() + monitored_root_folders.len() + 1, + ) + .collect(), + ); + + let stat_item_areas = Layout::vertical(constraints).margin(1).split(area); + + let version_paragraph = Paragraph::new(Text::from(format!( + "Readarr Version: {}", + app.data.readarr_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..monitored_disk_space_vec.len() { + let DiskSpace { + path, + free_space, + total_space, + } = &monitored_disk_space_vec[i]; + let title = if let Some(path) = path { + path + } else { + &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[monitored_disk_space_vec.len() + 3]); + + for i in 0..monitored_root_folders.len() { + let RootFolder { + path, free_space, .. + } = &monitored_root_folders[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_color(); + + f.render_widget( + root_folder_space, + stat_item_areas[i + monitored_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.readarr_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 i64) / 2) - 1; + let items = cmp::min(downloads_vec.len(), max_items.unsigned_abs() as usize); + let download_item_areas = + Layout::vertical(iter::repeat_n(Constraint::Length(2), 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_readarr_logo(f: &mut Frame<'_>, area: Rect) { + let logo_text = Text::from(READARR_LOGO); + let logo = Paragraph::new(logo_text) + .light_magenta() + .block(layout_block().default_color()) + .centered(); + f.render_widget(logo, area); +} diff --git a/src/ui/readarr_ui/readarr_ui_tests.rs b/src/ui/readarr_ui/readarr_ui_tests.rs new file mode 100644 index 00000000..57caeea3 --- /dev/null +++ b/src/ui/readarr_ui/readarr_ui_tests.rs @@ -0,0 +1,46 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::Route; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::ReadarrUi; + + #[test] + fn test_readarr_ui_accepts() { + for readarr_block in ActiveReadarrBlock::iter() { + assert!(ReadarrUi::accepts(Route::Readarr(readarr_block, None))); + } + } + + mod snapshot_tests { + use super::*; + use crate::app::App; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + use rstest::rstest; + + #[rstest] + #[case(ActiveReadarrBlock::Authors, 0)] + #[case(ActiveReadarrBlock::Downloads, 1)] + #[case(ActiveReadarrBlock::Blocklist, 2)] + #[case(ActiveReadarrBlock::History, 3)] + #[case(ActiveReadarrBlock::RootFolders, 4)] + #[case(ActiveReadarrBlock::Indexers, 5)] + #[case(ActiveReadarrBlock::System, 6)] + fn test_readarr_ui_renders_readarr_tabs( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.main_tabs.set_index(index); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("readarr_tabs_{active_readarr_block}"), output); + } + } +} diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap new file mode 100644 index 00000000..62866bc1 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap new file mode 100644 index 00000000..d3f01721 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap new file mode 100644 index 00000000..d3f01721 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap new file mode 100644 index 00000000..d3f01721 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap new file mode 100644 index 00000000..d3f01721 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap new file mode 100644 index 00000000..d3f01721 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap new file mode 100644 index 00000000..d3f01721 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap index 1e66720a..e7ffe6b2 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Lidarr Version: 1.2.3.4 ││Test download title ││ ⠀⠀⠀⣠⣴⣶⡿⠻⣿⣶⣦⣄⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap index 439c7410..23f6c68a 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Lidarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⠀⠀⣠⣴⣶⡿⠻⣿⣶⣦⣄⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap index 007e357d..1b2b382b 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Error | to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Some error │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap index 201b8a9f..cb483c72 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Lidarr Version: 1.2.3.4 ││Test download title ││ ⠀⠀⠀⣠⣴⣶⡿⠻⣿⣶⣦⣄⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap index 94ea3318..90709a10 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap index 321fb196..1238a5d7 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap index 12f05f7d..53dd902a 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Error | to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Some error │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_failure_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_failure_popup.snap index ce9f2c0c..21403c2d 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_failure_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_failure_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_success_popup.snap index ed22e921..84554a8a 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_success_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab.snap index 987742eb..187aaf8c 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Sonarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⣠⣴⣶⣿⣿⣿⣿⣶⣦⣄⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_error_popup.snap index 4bbce1e3..6a52d517 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_error_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Sonarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⠀⠀⣠⣴⣶⣿⣿⣿⣿⣶⣦⣄⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_with_error.snap index ffbf3868..e839c02a 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_with_error.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Error | to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Some error │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_notification_success_popup.snap index 7cd77bf0..3b7e7de1 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_notification_success_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Sonarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⣠⣴⣶⣿⣿⣿⣿⣶⣦⣄⠀⠀⠀ │ diff --git a/src/utils.rs b/src/utils.rs index 788a6a78..33a05929 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -365,6 +365,13 @@ pub fn select_cli_configuration( config.lidarr.as_ref().expect("Lidarr config must exist")[0].clone(); app.server_tabs.select_tab_by_config(&default_lidarr_config); } + Command::Readarr(_) => { + let default_readarr_config = + config.readarr.as_ref().expect("Readarr config must exist")[0].clone(); + app + .server_tabs + .select_tab_by_config(&default_readarr_config); + } _ => (), } } From cd341c3d71dad5f43484dc6a812773cd5712c380 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 15:44:07 -0600 Subject: [PATCH 004/170] feat(readarr): add the Readarr health check network call Inhabit ReadarrEvent with its first variant, HealthCheck, mapped to GET /health, and add get_readarr_healthcheck alongside the equivalent Lidarr, Radarr, and Sonarr fetchers. The call is internal only and has no CLI surface, matching the other three servarrs; it exists so that an unreachable instance surfaces an error banner on tick. The healthcheck fetcher lives in readarr_network/mod.rs rather than a submodule, so its tests live in readarr_network_tests.rs next to the resource() and NetworkEvent conversion tests, mirroring Lidarr. MockServarrApi::build_for matched NetworkEvent by value with no Radarr fallback arm. It compiled only while ReadarrEvent was uninhabited and no value could reach it; the first variant makes the match non exhaustive, so it now assigns the config to the Readarr server tab. --- src/network/network_tests.rs | 1 + src/network/readarr_network/mod.rs | 37 +++++++++++-- .../readarr_network/readarr_network_tests.rs | 53 +++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/network/readarr_network/readarr_network_tests.rs diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 7672144d..98f31151 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -992,6 +992,7 @@ pub(in crate::network) mod test_utils { NetworkEvent::Radarr(_) => app.server_tabs.tabs[0].config = Some(servarr_config), NetworkEvent::Sonarr(_) => app.server_tabs.tabs[1].config = Some(servarr_config), NetworkEvent::Lidarr(_) => app.server_tabs.tabs[2].config = Some(servarr_config), + NetworkEvent::Readarr(_) => app.server_tabs.tabs[3].config = Some(servarr_config), } let app_arc = Arc::new(Mutex::new(app)); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 747f5a65..b47a0032 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -1,15 +1,24 @@ use anyhow::Result; +use log::info; use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::ReadarrSerdeable; -use crate::network::Network; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_network_tests.rs"] +mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] -pub enum ReadarrEvent {} +pub enum ReadarrEvent { + HealthCheck, +} impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { - match *self {} + match &self { + ReadarrEvent::HealthCheck => "/health", + } } } @@ -24,6 +33,26 @@ impl Network<'_, '_> { &mut self, readarr_event: ReadarrEvent, ) -> Result { - match readarr_event {} + match readarr_event { + ReadarrEvent::HealthCheck => self + .get_readarr_healthcheck() + .await + .map(ReadarrSerdeable::from), + } + } + + pub(in crate::network::readarr_network) async fn get_readarr_healthcheck( + &mut self, + ) -> Result<()> { + info!("Performing Readarr health check"); + let event = ReadarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs new file mode 100644 index 00000000..699ebd89 --- /dev/null +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -0,0 +1,53 @@ +#[cfg(test)] +mod tests { + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + + #[rstest] + #[case(ReadarrEvent::HealthCheck, "/health")] + fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { + assert_str_eq!(event.resource(), expected_uri); + } + + #[test] + fn test_from_readarr_event() { + assert_eq!( + NetworkEvent::Readarr(ReadarrEvent::HealthCheck), + NetworkEvent::from(ReadarrEvent::HealthCheck) + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_healthcheck_event() { + let (mock, app, _server) = MockServarrApi::get() + .build_for(ReadarrEvent::HealthCheck) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let _ = network + .handle_readarr_event(ReadarrEvent::HealthCheck) + .await; + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_readarr_healthcheck_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .status(500) + .build_for(ReadarrEvent::HealthCheck) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::HealthCheck) + .await; + + mock.assert_async().await; + assert_err!(result); + } +} From d8f171ec6fadbf70b28b40b7df6dbead8aa2d6d4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 15:47:40 -0600 Subject: [PATCH 005/170] feat(readarr): fetch the Readarr system status and expose it on the CLI Add ReadarrEvent::GetStatus, mapped to GET /system/status, and get_readarr_status in the new readarr_network/system module. It stores the version and start time on ReadarrData so the system view and the tick refresh can read them. Surface it as `managarr readarr get system-status`, matching the Radarr, Sonarr, and Lidarr spelling of the same call. A `list status` spelling would have made the same command a different verb on the fourth servarr, which is a difference the user would have to remember for no benefit. Readarr instances answer /system/status with thirty keys; SystemStatus models the two that are used and serde ignores the rest, as it does for the other three servarrs. This is the first ReadarrCommand variant, so ReadarrCliHandler now reads its app and network fields. --- src/cli/readarr/get_command_handler.rs | 63 ++++++++++++++++ src/cli/readarr/get_command_handler_tests.rs | 72 +++++++++++++++++++ src/cli/readarr/mod.rs | 21 +++++- src/network/readarr_network/mod.rs | 5 ++ .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 29 ++++++++ .../system/readarr_system_network_tests.rs | 59 +++++++++++++++ 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 src/cli/readarr/get_command_handler.rs create mode 100644 src/cli/readarr/get_command_handler_tests.rs create mode 100644 src/network/readarr_network/system/mod.rs create mode 100644 src/network/readarr_network/system/readarr_system_network_tests.rs diff --git a/src/cli/readarr/get_command_handler.rs b/src/cli/readarr/get_command_handler.rs new file mode 100644 index 00000000..4362793e --- /dev/null +++ b/src/cli/readarr/get_command_handler.rs @@ -0,0 +1,63 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +use super::ReadarrCommand; + +#[cfg(test)] +#[path = "get_command_handler_tests.rs"] +mod get_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrGetCommand { + #[command(about = "Get the system status")] + SystemStatus, +} + +impl From for Command { + fn from(value: ReadarrGetCommand) -> Self { + Command::Readarr(ReadarrCommand::Get(value)) + } +} + +pub(super) struct ReadarrGetCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrGetCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrGetCommand> for ReadarrGetCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: ReadarrGetCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrGetCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrGetCommand::SystemStatus => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetStatus.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/get_command_handler_tests.rs b/src/cli/readarr/get_command_handler_tests.rs new file mode 100644 index 00000000..822123bb --- /dev/null +++ b/src/cli/readarr/get_command_handler_tests.rs @@ -0,0 +1,72 @@ +#[cfg(test)] +mod tests { + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ReadarrCommand, get_command_handler::ReadarrGetCommand}, + }; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_get_command_from() { + let command = ReadarrGetCommand::SystemStatus; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Get(command))); + } + + mod cli { + use super::*; + use rstest::rstest; + + #[rstest] + fn test_get_commands_have_no_arg_requirements(#[values("system-status")] subcommand: &str) { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "get", subcommand]); + + 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::{ + CliCommandHandler, + readarr::get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}, + }, + models::{Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_get_system_status_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_system_status_command = ReadarrGetCommand::SystemStatus; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 7797af94..1bd27875 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -2,17 +2,26 @@ use std::sync::Arc; use anyhow::Result; use clap::Subcommand; +use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use tokio::sync::Mutex; use super::{CliCommandHandler, Command}; use crate::{app::App, network::NetworkTrait}; +mod get_command_handler; + #[cfg(test)] #[path = "readarr_command_tests.rs"] mod readarr_command_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] -pub enum ReadarrCommand {} +pub enum ReadarrCommand { + #[command( + subcommand, + about = "Commands to fetch details of the resources in your Readarr instance" + )] + Get(ReadarrGetCommand), +} impl From for Command { fn from(readarr_command: ReadarrCommand) -> Command { @@ -40,6 +49,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, } async fn handle(self) -> Result { - match self.command {} + let result = match self.command { + ReadarrCommand::Get(get_command) => { + ReadarrGetCommandHandler::with(self.app, get_command, self.network) + .handle() + .await? + } + }; + + Ok(result) } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index b47a0032..7be4c816 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -5,12 +5,15 @@ use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::ReadarrSerdeable; use crate::network::{Network, RequestMethod}; +mod system; + #[cfg(test)] #[path = "readarr_network_tests.rs"] mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { + GetStatus, HealthCheck, } @@ -18,6 +21,7 @@ impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { ReadarrEvent::HealthCheck => "/health", + ReadarrEvent::GetStatus => "/system/status", } } } @@ -34,6 +38,7 @@ impl Network<'_, '_> { readarr_event: ReadarrEvent, ) -> Result { match readarr_event { + ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), ReadarrEvent::HealthCheck => self .get_readarr_healthcheck() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 699ebd89..f20aa015 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -7,6 +7,7 @@ mod tests { #[rstest] #[case(ReadarrEvent::HealthCheck, "/health")] + #[case(ReadarrEvent::GetStatus, "/system/status")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { assert_str_eq!(event.resource(), expected_uri); } diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs new file mode 100644 index 00000000..1ac5a65b --- /dev/null +++ b/src/network/readarr_network/system/mod.rs @@ -0,0 +1,29 @@ +use crate::models::servarr_models::SystemStatus; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; + +#[cfg(test)] +#[path = "readarr_system_network_tests.rs"] +mod readarr_system_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_status( + &mut self, + ) -> Result { + info!("Fetching Readarr system status"); + let event = ReadarrEvent::GetStatus; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { + app.data.readarr_data.version = system_status.version; + app.data.readarr_data.start_time = system_status.start_time; + }) + .await + } +} diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs new file mode 100644 index 00000000..dc9de9c6 --- /dev/null +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -0,0 +1,59 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::servarr_models::SystemStatus; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::json; + + #[tokio::test] + async fn test_handle_get_readarr_status_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({ + "version": "0.4.20.129", + "startTime": "2026-08-17T03:55:02Z" + })) + .build_for(ReadarrEvent::GetStatus) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + let date_time = DateTime::from(DateTime::parse_from_rfc3339("2026-08-17T03:55:02Z").unwrap()); + + let result = network.handle_readarr_event(ReadarrEvent::GetStatus).await; + + mock.assert_async().await; + + let ReadarrSerdeable::SystemStatus(status) = result.unwrap() else { + panic!("Expected SystemStatus") + }; + + assert_eq!( + status, + SystemStatus { + version: "0.4.20.129".to_owned(), + start_time: date_time + } + ); + assert_str_eq!(app.lock().await.data.readarr_data.version, "0.4.20.129"); + assert_eq!(app.lock().await.data.readarr_data.start_time, date_time); + } + + #[tokio::test] + async fn test_handle_get_readarr_status_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetStatus) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetStatus).await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.version); + } +} From 520b422dcce8a391f430a8ec46e90b0eb0115822 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 15:49:56 -0600 Subject: [PATCH 006/170] feat(readarr): fetch Readarr disk space and expose it on the CLI Add ReadarrEvent::GetDiskSpace, mapped to GET /diskspace, and get_readarr_diskspace, which stores the result on ReadarrData for the root folder and system views. Surface it as `managarr readarr list disk-space`, matching the other three servarrs. Readarr returns a label field on some records that DiskSpace does not model; serde drops it, so the CLI prints only the path and the free and total space, as it does for the other three servarrs. --- src/cli/readarr/list_command_handler.rs | 62 ++++++++++++++++ src/cli/readarr/list_command_handler_tests.rs | 73 +++++++++++++++++++ src/cli/readarr/mod.rs | 12 +++ src/network/readarr_network/mod.rs | 6 ++ .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 19 ++++- .../system/readarr_system_network_tests.rs | 71 +++++++++++++++++- 7 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 src/cli/readarr/list_command_handler.rs create mode 100644 src/cli/readarr/list_command_handler_tests.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs new file mode 100644 index 00000000..55c2cb76 --- /dev/null +++ b/src/cli/readarr/list_command_handler.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "list_command_handler_tests.rs"] +mod list_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrListCommand { + #[command(about = "List disk space details for all provisioned root folders in Readarr")] + DiskSpace, +} + +impl From for Command { + fn from(value: ReadarrListCommand) -> Self { + Command::Readarr(ReadarrCommand::List(value)) + } +} + +pub(super) struct ReadarrListCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrListCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: ReadarrListCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrListCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrListCommand::DiskSpace => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetDiskSpace.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs new file mode 100644 index 00000000..85568cad --- /dev/null +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -0,0 +1,73 @@ +#[cfg(test)] +mod tests { + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ReadarrCommand, list_command_handler::ReadarrListCommand}, + }; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_list_command_from() { + let command = ReadarrListCommand::DiskSpace; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::List(command))); + } + + mod cli { + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_list_commands_have_no_arg_requirements(#[values("disk-space")] subcommand: &str) { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", subcommand]); + + 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::{ + CliCommandHandler, + readarr::list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}, + }, + models::{Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_list_disk_space_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetDiskSpace.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_disk_space_command = ReadarrListCommand::DiskSpace; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_disk_space_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 1bd27875..1b8ac8f5 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -3,12 +3,14 @@ use std::sync::Arc; use anyhow::Result; use clap::Subcommand; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; +use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use tokio::sync::Mutex; use super::{CliCommandHandler, Command}; use crate::{app::App, network::NetworkTrait}; mod get_command_handler; +mod list_command_handler; #[cfg(test)] #[path = "readarr_command_tests.rs"] @@ -21,6 +23,11 @@ pub enum ReadarrCommand { about = "Commands to fetch details of the resources in your Readarr instance" )] Get(ReadarrGetCommand), + #[command( + subcommand, + about = "Commands to list attributes from your Readarr instance" + )] + List(ReadarrListCommand), } impl From for Command { @@ -55,6 +62,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::List(list_command) => { + ReadarrListCommandHandler::with(self.app, list_command, self.network) + .handle() + .await? + } }; Ok(result) diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 7be4c816..17e3a1c0 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -13,6 +13,7 @@ mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { + GetDiskSpace, GetStatus, HealthCheck, } @@ -20,6 +21,7 @@ pub enum ReadarrEvent { impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { + ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetStatus => "/system/status", } @@ -38,6 +40,10 @@ impl Network<'_, '_> { readarr_event: ReadarrEvent, ) -> Result { match readarr_event { + ReadarrEvent::GetDiskSpace => self + .get_readarr_diskspace() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), ReadarrEvent::HealthCheck => self .get_readarr_healthcheck() diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index f20aa015..a8703242 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -6,6 +6,7 @@ mod tests { use rstest::rstest; #[rstest] + #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetStatus, "/system/status")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index 1ac5a65b..4660f761 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,4 +1,4 @@ -use crate::models::servarr_models::SystemStatus; +use crate::models::servarr_models::{DiskSpace, SystemStatus}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; @@ -9,6 +9,23 @@ use log::info; mod readarr_system_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_diskspace( + &mut self, + ) -> Result> { + info!("Fetching Readarr disk space"); + let event = ReadarrEvent::GetDiskSpace; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { + app.data.readarr_data.disk_space_vec = disk_space_vec; + }) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_status( &mut self, ) -> Result { diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index dc9de9c6..50de66d9 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -1,13 +1,82 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::ReadarrSerdeable; - use crate::models::servarr_models::SystemStatus; + use crate::models::servarr_models::{DiskSpace, SystemStatus}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use chrono::DateTime; use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::json; + #[tokio::test] + async fn test_handle_get_readarr_diskspace_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([ + { + "path": "/nfs/books/", + "freeSpace": 11039526748160i64, + "totalSpace": 117810875334656i64 + }, + { + "path": "/config", + "label": "", + "freeSpace": 783978655744i64, + "totalSpace": 1004298338304i64 + } + ])) + .build_for(ReadarrEvent::GetDiskSpace) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + let disk_space_vec = vec![ + DiskSpace { + path: Some("/nfs/books/".to_owned()), + free_space: 11039526748160, + total_space: 117810875334656, + }, + DiskSpace { + path: Some("/config".to_owned()), + free_space: 783978655744, + total_space: 1004298338304, + }, + ]; + + let result = network + .handle_readarr_event(ReadarrEvent::GetDiskSpace) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::DiskSpaces(disk_spaces) = result.unwrap() else { + panic!("Expected DiskSpaces") + }; + + assert_eq!(disk_spaces, disk_space_vec); + assert_eq!( + app.lock().await.data.readarr_data.disk_space_vec, + disk_space_vec + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_diskspace_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetDiskSpace) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetDiskSpace) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.disk_space_vec); + } + #[tokio::test] async fn test_handle_get_readarr_status_event() { let (mock, app, _server) = MockServarrApi::get() From 10725859039f7358202daed41b01c96b52220c9d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 16:07:33 -0600 Subject: [PATCH 007/170] feat(readarr): fetch the Readarr host config and expose it on the CLI --- src/cli/readarr/get_command_handler.rs | 9 +++ src/cli/readarr/get_command_handler_tests.rs | 27 +++++++- src/network/readarr_network/mod.rs | 6 ++ .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 17 ++++- .../system/readarr_system_network_tests.rs | 66 ++++++++++++++++++- 6 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/cli/readarr/get_command_handler.rs b/src/cli/readarr/get_command_handler.rs index 4362793e..d3420a37 100644 --- a/src/cli/readarr/get_command_handler.rs +++ b/src/cli/readarr/get_command_handler.rs @@ -18,6 +18,8 @@ mod get_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrGetCommand { + #[command(about = "Fetch the host config for your Readarr instance")] + HostConfig, #[command(about = "Get the system status")] SystemStatus, } @@ -49,6 +51,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrGetCommand> for ReadarrGetCommandH async fn handle(self) -> Result { let result = match self.command { + ReadarrGetCommand::HostConfig => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetHostConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrGetCommand::SystemStatus => { let resp = self .network diff --git a/src/cli/readarr/get_command_handler_tests.rs b/src/cli/readarr/get_command_handler_tests.rs index 822123bb..30214de0 100644 --- a/src/cli/readarr/get_command_handler_tests.rs +++ b/src/cli/readarr/get_command_handler_tests.rs @@ -22,7 +22,9 @@ mod tests { use rstest::rstest; #[rstest] - fn test_get_commands_have_no_arg_requirements(#[values("system-status")] subcommand: &str) { + fn test_get_commands_have_no_arg_requirements( + #[values("host-config", "system-status")] subcommand: &str, + ) { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "get", subcommand]); assert_ok!(&result); @@ -46,6 +48,29 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; + #[tokio::test] + async fn test_handle_get_host_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetHostConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_host_config_command = ReadarrGetCommand::HostConfig; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_get_system_status_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 17e3a1c0..19349c54 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -14,6 +14,7 @@ mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { GetDiskSpace, + GetHostConfig, GetStatus, HealthCheck, } @@ -21,6 +22,7 @@ pub enum ReadarrEvent { impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { + ReadarrEvent::GetHostConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetStatus => "/system/status", @@ -44,6 +46,10 @@ impl Network<'_, '_> { .get_readarr_diskspace() .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetHostConfig => self + .get_readarr_host_config() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), ReadarrEvent::HealthCheck => self .get_readarr_healthcheck() diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index a8703242..f55f6bdd 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -6,6 +6,7 @@ mod tests { use rstest::rstest; #[rstest] + #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetStatus, "/system/status")] diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index 4660f761..93d63ad4 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,4 +1,4 @@ -use crate::models::servarr_models::{DiskSpace, SystemStatus}; +use crate::models::servarr_models::{DiskSpace, HostConfig, SystemStatus}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; @@ -26,6 +26,21 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_readarr_host_config( + &mut self, + ) -> Result { + info!("Fetching Readarr host config"); + let event = ReadarrEvent::GetHostConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), HostConfig>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_status( &mut self, ) -> Result { diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index 50de66d9..038f63c9 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -1,7 +1,8 @@ #[cfg(test)] mod tests { + use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::ReadarrSerdeable; - use crate::models::servarr_models::{DiskSpace, SystemStatus}; + use crate::models::servarr_models::{DiskSpace, HostConfig, SystemStatus}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use chrono::DateTime; @@ -77,6 +78,69 @@ mod tests { assert_is_empty!(app.lock().await.data.readarr_data.disk_space_vec); } + #[tokio::test] + async fn test_handle_get_readarr_host_config_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({ + "bindAddress": "*", + "port": 8787, + "urlBase": "", + "instanceName": "Readarr", + "applicationUrl": "", + "enableSsl": false, + "sslPort": 6868, + "sslCertPath": "", + "sslCertPassword": "" + })) + .build_for(ReadarrEvent::GetHostConfig) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHostConfig) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::HostConfig(host_config) = result.unwrap() else { + panic!("Expected HostConfig") + }; + + assert_eq!( + host_config, + HostConfig { + bind_address: "*".into(), + port: 8787, + url_base: Some(HorizontallyScrollableText::default()), + instance_name: Some("Readarr".into()), + application_url: Some(HorizontallyScrollableText::default()), + enable_ssl: false, + ssl_port: 6868, + ssl_cert_path: Some(String::new()), + ssl_cert_password: Some(String::new()), + } + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_host_config_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetHostConfig) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHostConfig) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_readarr_status_event() { let (mock, app, _server) = MockServarrApi::get() From 97d67a6938eb5e17afa053cb55320a9f588304bd Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 16:15:34 -0600 Subject: [PATCH 008/170] feat(readarr): fetch the Readarr security config and expose it on the CLI --- src/cli/readarr/get_command_handler.rs | 9 +++ src/cli/readarr/get_command_handler_tests.rs | 25 +++++++- src/network/readarr_network/mod.rs | 7 ++- .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 17 ++++- .../system/readarr_system_network_tests.rs | 62 ++++++++++++++++++- 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/cli/readarr/get_command_handler.rs b/src/cli/readarr/get_command_handler.rs index d3420a37..a0b8b6bb 100644 --- a/src/cli/readarr/get_command_handler.rs +++ b/src/cli/readarr/get_command_handler.rs @@ -20,6 +20,8 @@ mod get_command_handler_tests; pub enum ReadarrGetCommand { #[command(about = "Fetch the host config for your Readarr instance")] HostConfig, + #[command(about = "Fetch the security config for your Readarr instance")] + SecurityConfig, #[command(about = "Get the system status")] SystemStatus, } @@ -58,6 +60,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrGetCommand> for ReadarrGetCommandH .await?; serde_json::to_string_pretty(&resp)? } + ReadarrGetCommand::SecurityConfig => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetSecurityConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrGetCommand::SystemStatus => { let resp = self .network diff --git a/src/cli/readarr/get_command_handler_tests.rs b/src/cli/readarr/get_command_handler_tests.rs index 30214de0..5bd8ccc9 100644 --- a/src/cli/readarr/get_command_handler_tests.rs +++ b/src/cli/readarr/get_command_handler_tests.rs @@ -23,7 +23,7 @@ mod tests { #[rstest] fn test_get_commands_have_no_arg_requirements( - #[values("host-config", "system-status")] subcommand: &str, + #[values("host-config", "security-config", "system-status")] subcommand: &str, ) { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "get", subcommand]); @@ -71,6 +71,29 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_get_security_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetSecurityConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_security_config_command = ReadarrGetCommand::SecurityConfig; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_security_config_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_get_system_status_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 19349c54..b61e1875 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -15,6 +15,7 @@ mod readarr_network_tests; pub enum ReadarrEvent { GetDiskSpace, GetHostConfig, + GetSecurityConfig, GetStatus, HealthCheck, } @@ -22,7 +23,7 @@ pub enum ReadarrEvent { impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { - ReadarrEvent::GetHostConfig => "/config/host", + ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetStatus => "/system/status", @@ -50,6 +51,10 @@ impl Network<'_, '_> { .get_readarr_host_config() .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetSecurityConfig => self + .get_readarr_security_config() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), ReadarrEvent::HealthCheck => self .get_readarr_healthcheck() diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index f55f6bdd..66899705 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -7,6 +7,7 @@ mod tests { #[rstest] #[case(ReadarrEvent::GetHostConfig, "/config/host")] + #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetStatus, "/system/status")] diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index 93d63ad4..013c240a 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,4 +1,4 @@ -use crate::models::servarr_models::{DiskSpace, HostConfig, SystemStatus}; +use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig, SystemStatus}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; @@ -41,6 +41,21 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_readarr_security_config( + &mut self, + ) -> Result { + info!("Fetching Readarr security config"); + let event = ReadarrEvent::GetSecurityConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_status( &mut self, ) -> Result { diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index 038f63c9..512bf6b1 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -2,7 +2,10 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::ReadarrSerdeable; - use crate::models::servarr_models::{DiskSpace, HostConfig, SystemStatus}; + use crate::models::servarr_models::{ + AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, + SecurityConfig, SystemStatus, + }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use chrono::DateTime; @@ -141,6 +144,63 @@ mod tests { assert_err!(result); } + #[tokio::test] + async fn test_handle_get_readarr_security_config_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({ + "authenticationMethod": "forms", + "authenticationRequired": "enabled", + "username": "test", + "password": "test", + "apiKey": "test1234", + "certificateValidation": "enabled" + })) + .build_for(ReadarrEvent::GetSecurityConfig) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetSecurityConfig) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::SecurityConfig(security_config) = result.unwrap() else { + panic!("Expected SecurityConfig") + }; + + assert_eq!( + security_config, + SecurityConfig { + authentication_method: AuthenticationMethod::Forms, + authentication_required: Some(AuthenticationRequired::Enabled), + username: Some("test".to_owned()), + password: Some("test".to_owned()), + api_key: "test1234".to_owned(), + certificate_validation: CertificateValidation::Enabled, + } + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_security_config_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetSecurityConfig) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetSecurityConfig) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_readarr_status_event() { let (mock, app, _server) = MockServarrApi::get() From e0e82e654e08f9f0c39366db1dc48f005cb7146a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 21:06:28 -0600 Subject: [PATCH 009/170] fix: tolerate update records that omit the changes list The changes field was required, so a single record without it failed the whole response and emptied the updates list. A live instance returns five update records of which one has no changes key, so this is reachable rather than theoretical. This affects every servarr, since Update is shared. --- src/models/servarr_models.rs | 1 + src/models/servarr_models_tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 71c74fb9..ef9d09a3 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -354,6 +354,7 @@ pub struct Update { pub installed: bool, pub latest: bool, pub installed_on: Option>, + #[serde(default)] pub changes: UpdateChanges, } diff --git a/src/models/servarr_models_tests.rs b/src/models/servarr_models_tests.rs index e57dac7d..52dd4f2f 100644 --- a/src/models/servarr_models_tests.rs +++ b/src/models/servarr_models_tests.rs @@ -4,6 +4,7 @@ mod tests { use crate::models::servarr_models::{ AuthenticationMethod, AuthenticationRequired, CertificateValidation, Indexer, QualityProfile, + Update, UpdateChanges, }; #[test] @@ -67,4 +68,26 @@ mod tests { assert_eq!(expected_quality_profile, quality_profile); } + + #[test] + fn test_update_deserializes_when_changes_is_absent() { + let update_json = r#"{ + "version": "0.4.18.2805", + "releaseDate": "2025-06-15T06:22:22Z", + "installed": false, + "latest": true, + "installedOn": "2025-06-22T05:09:11Z" + }"#; + + let update = serde_json::from_str::(update_json); + + assert_ok!(&update); + assert_eq!( + update.unwrap().changes, + UpdateChanges { + new: None, + fixed: None, + } + ); + } } From c6d729162a6f0f4cf37582eab2fe86c3e7ac109e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 21:22:31 -0600 Subject: [PATCH 010/170] feat(readarr): fetch Readarr updates and expose them on the CLI Adds ReadarrEvent::GetUpdates backed by get_readarr_updates, rendering the update history into the Readarr updates view, and surfaces it as 'readarr list updates'. Covers the case where an update record omits the changes key, which the live instance returns for its most recent release. --- src/cli/readarr/list_command_handler.rs | 9 ++ src/cli/readarr/list_command_handler_tests.rs | 27 ++++- src/network/readarr_network/mod.rs | 3 + .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 84 +++++++++++++- .../system/readarr_system_network_tests.rs | 104 +++++++++++++++++- 6 files changed, 225 insertions(+), 3 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 55c2cb76..33959fbf 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -19,6 +19,8 @@ mod list_command_handler_tests; pub enum ReadarrListCommand { #[command(about = "List disk space details for all provisioned root folders in Readarr")] DiskSpace, + #[command(about = "List all Readarr updates")] + Updates, } impl From for Command { @@ -55,6 +57,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Updates => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetUpdates.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 85568cad..88f4958d 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -23,7 +23,9 @@ mod tests { use super::*; #[rstest] - fn test_list_commands_have_no_arg_requirements(#[values("disk-space")] subcommand: &str) { + fn test_list_commands_have_no_arg_requirements( + #[values("disk-space", "updates")] subcommand: &str, + ) { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", subcommand]); assert_ok!(&result); @@ -69,5 +71,28 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_handle_list_updates_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetUpdates.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_updates_command = ReadarrListCommand::Updates; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_updates_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index b61e1875..d8313d75 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -17,6 +17,7 @@ pub enum ReadarrEvent { GetHostConfig, GetSecurityConfig, GetStatus, + GetUpdates, HealthCheck, } @@ -27,6 +28,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetStatus => "/system/status", + ReadarrEvent::GetUpdates => "/update", } } } @@ -56,6 +58,7 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), + ReadarrEvent::GetUpdates => self.get_readarr_updates().await.map(ReadarrSerdeable::from), ReadarrEvent::HealthCheck => self .get_readarr_healthcheck() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 66899705..5375665c 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -11,6 +11,7 @@ mod tests { #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetStatus, "/system/status")] + #[case(ReadarrEvent::GetUpdates, "/update")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { assert_str_eq!(event.resource(), expected_uri); } diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index 013c240a..47a8cd3b 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,7 +1,9 @@ -use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig, SystemStatus}; +use crate::models::ScrollableText; +use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig, SystemStatus, Update}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; +use indoc::formatdoc; use log::info; #[cfg(test)] @@ -73,4 +75,84 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn get_readarr_updates( + &mut self, + ) -> Result> { + info!("Fetching Readarr updates"); + let event = ReadarrEvent::GetUpdates; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |updates_vec, mut app| { + let latest_installed = if updates_vec + .iter() + .any(|update| update.latest && update.installed_on.is_some()) + { + "already".to_owned() + } else { + "not".to_owned() + }; + let updates = updates_vec + .into_iter() + .map(|update| { + let install_status = if update.installed_on.is_some() { + if update.installed { + " (Currently Installed)".to_owned() + } else { + " (Previously Installed)".to_owned() + } + } else { + String::new() + }; + let vec_to_bullet_points = |vec: Vec| { + vec + .iter() + .map(|change| format!(" * {change}")) + .collect::>() + .join("\n") + }; + + let mut update_info = formatdoc!( + "{} - {}{install_status} + {}", + update.version, + update.release_date, + "-".repeat(200) + ); + + if let Some(new_changes) = update.changes.new { + let changes = vec_to_bullet_points(new_changes); + update_info = formatdoc!( + "{update_info} + New: + {changes}" + ) + } + + if let Some(fixes) = update.changes.fixed { + let fixes = vec_to_bullet_points(fixes); + update_info = formatdoc!( + "{update_info} + Fixed: + {fixes}" + ); + } + + update_info + }) + .reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}")) + .unwrap(); + + app.data.readarr_data.updates = ScrollableText::with_string(formatdoc!( + "The latest version of Readarr is {latest_installed} installed + + {updates}" + )); + }) + .await + } } diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index 512bf6b1..958448cf 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -4,11 +4,12 @@ mod tests { use crate::models::readarr_models::ReadarrSerdeable; use crate::models::servarr_models::{ AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, - SecurityConfig, SystemStatus, + SecurityConfig, SystemStatus, Update, }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use chrono::DateTime; + use indoc::formatdoc; use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::json; @@ -249,4 +250,105 @@ mod tests { assert_err!(result); assert_is_empty!(app.lock().await.data.readarr_data.version); } + + #[tokio::test] + async fn test_handle_get_readarr_updates_event() { + let updates_json = json!([{ + "version": "4.3.2.1", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": true, + "installedOn": "2023-04-15T02:02:53Z", + "latest": true, + "changes": { "new": ["Cool new thing"], "fixed": ["Some bugs killed"] }, + }, + { + "version": "3.2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "installedOn": "2023-04-15T02:02:53Z", + "latest": false, + "changes": { "new": ["Cool new thing (old)", "Other cool new thing (old)"] }, + }, + { + "version": "2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + "changes": { "fixed": ["Killed bug 1", "Fixed bug 2"] }, + }, + { + "version": "1.0.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + }]); + let response: Vec = serde_json::from_value(updates_json.clone()).unwrap(); + let line_break = "-".repeat(200); + let expected_text = formatdoc!( + "The latest version of Readarr is already installed + + 4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) + {line_break} + New: + * Cool new thing + Fixed: + * Some bugs killed + + + 3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) + {line_break} + New: + * Cool new thing (old) + * Other cool new thing (old) + + + 2.1.0 - 2023-04-15 02:02:53 UTC + {line_break} + Fixed: + * Killed bug 1 + * Fixed bug 2 + + + 1.0.0 - 2023-04-15 02:02:53 UTC + {line_break}" + ); + let (mock, app, _server) = MockServarrApi::get() + .returns(updates_json) + .build_for(ReadarrEvent::GetUpdates) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetUpdates).await; + + mock.assert_async().await; + + let ReadarrSerdeable::Updates(updates) = result.unwrap() else { + panic!("Expected Updates") + }; + + assert_eq!(updates, response); + + assert_str_eq!( + app.lock().await.data.readarr_data.updates.get_text(), + expected_text + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_updates_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetUpdates) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetUpdates).await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.updates.get_text()); + } } From 690a17e1525ac0a7f9cea76f0b44e2455e95ebdf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 21:33:57 -0600 Subject: [PATCH 011/170] feat(readarr): fetch Readarr logs and expose them on the CLI Adds ReadarrEvent::GetLogs backed by get_readarr_logs, which formats each record into the Readarr logs list, and surfaces it as 'readarr list logs --events N'. The --output-in-log-format branch reads the Readarr log lines, and is covered by a test that pins that source so it cannot silently drift to another servarr's data. --- src/cli/readarr/list_command_handler.rs | 33 ++++++- src/cli/readarr/list_command_handler_tests.rs | 95 ++++++++++++++++++- src/network/readarr_network/mod.rs | 6 ++ .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 62 +++++++++++- .../system/readarr_system_network_tests.rs | 88 ++++++++++++++++- 6 files changed, 276 insertions(+), 9 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 33959fbf..85909d48 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -19,6 +19,16 @@ mod list_command_handler_tests; pub enum ReadarrListCommand { #[command(about = "List disk space details for all provisioned root folders in Readarr")] DiskSpace, + #[command(about = "Fetch Readarr logs")] + Logs { + #[arg(long, help = "How many log events to fetch", default_value_t = 500)] + events: u64, + #[arg( + long, + help = "Output the logs in the same format as they appear in the log files" + )] + output_in_log_format: bool, + }, #[command(about = "List all Readarr updates")] Updates, } @@ -30,19 +40,19 @@ impl From for Command { } pub(super) struct ReadarrListCommandHandler<'a, 'b> { - _app: &'a Arc>>, + app: &'a Arc>>, command: ReadarrListCommand, network: &'a mut dyn NetworkTrait, } impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListCommandHandler<'a, 'b> { fn with( - _app: &'a Arc>>, + app: &'a Arc>>, command: ReadarrListCommand, network: &'a mut dyn NetworkTrait, ) -> Self { ReadarrListCommandHandler { - _app, + app, command, network, } @@ -57,6 +67,23 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Logs { + events, + output_in_log_format, + } => { + let logs = self + .network + .handle_network_event(ReadarrEvent::GetLogs(events).into()) + .await?; + + if output_in_log_format { + let log_lines = &self.app.lock().await.data.readarr_data.logs.items; + + serde_json::to_string_pretty(log_lines)? + } else { + serde_json::to_string_pretty(&logs)? + } + } ReadarrListCommand::Updates => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 88f4958d..3a9db21e 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -18,9 +18,10 @@ mod tests { } mod cli { - use rstest::rstest; - use super::*; + use clap::{Parser, error::ErrorKind}; + use pretty_assertions::assert_eq; + use rstest::rstest; #[rstest] fn test_list_commands_have_no_arg_requirements( @@ -30,12 +31,39 @@ mod tests { assert_ok!(&result); } + + #[test] + fn test_list_logs_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "logs", "--events"]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_logs_default_values() { + let expected_args = ReadarrListCommand::Logs { + events: 500, + output_in_log_format: false, + }; + let result = Cli::try_parse_from(["managarr", "readarr", "list", "logs"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(logs_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(logs_command, expected_args); + } } mod handler { use std::sync::Arc; use mockall::predicate::eq; + use pretty_assertions::assert_str_eq; use serde_json::json; use tokio::sync::Mutex; @@ -45,7 +73,7 @@ mod tests { CliCommandHandler, readarr::list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}, }, - models::{Serdeable, readarr_models::ReadarrSerdeable}, + models::{HorizontallyScrollableText, Serdeable, readarr_models::ReadarrSerdeable}, network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; @@ -72,6 +100,67 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_logs_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetLogs(expected_events).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_logs_command = ReadarrListCommand::Logs { + events: 1000, + output_in_log_format: false, + }; + + let result = ReadarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_logs_command_output_in_log_format() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetLogs(1000).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + app_arc + .lock() + .await + .data + .readarr_data + .logs + .set_items(vec![HorizontallyScrollableText::from("readarr log line")]); + let list_logs_command = ReadarrListCommand::Logs { + events: 1000, + output_in_log_format: true, + }; + + let result = ReadarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + assert_str_eq!(result.unwrap(), "[\n \"readarr log line\"\n]"); + } + #[tokio::test] async fn test_handle_list_updates_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index d8313d75..91d407fc 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -15,6 +15,7 @@ mod readarr_network_tests; pub enum ReadarrEvent { GetDiskSpace, GetHostConfig, + GetLogs(u64), GetSecurityConfig, GetStatus, GetUpdates, @@ -27,6 +28,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", + ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetUpdates => "/update", } @@ -53,6 +55,10 @@ impl Network<'_, '_> { .get_readarr_host_config() .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetLogs(events) => self + .get_readarr_logs(events) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetSecurityConfig => self .get_readarr_security_config() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 5375665c..4b1cf495 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -10,6 +10,7 @@ mod tests { #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] + #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetUpdates, "/update")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index 47a8cd3b..4b960ec6 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,5 +1,7 @@ -use crate::models::ScrollableText; -use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig, SystemStatus, Update}; +use crate::models::servarr_models::{ + DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update, +}; +use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; @@ -43,6 +45,62 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_readarr_logs( + &mut self, + events: u64, + ) -> Result { + info!("Fetching Readarr logs"); + let event = ReadarrEvent::GetLogs(events); + + let params = format!("pageSize={events}&sortDirection=descending&sortKey=time"); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), LogResponse>(request_props, |log_response, mut app| { + let mut logs = log_response.records; + logs.reverse(); + + let log_lines = logs + .into_iter() + .map(|log| { + if let Some(exception) = log.exception { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log + .logger + .as_ref() + .expect("logger must exist when exception is present"), + log + .exception_type + .as_ref() + .expect("exception_type must exist when exception is present"), + exception + )) + } else { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().expect("logger must exist in log entry"), + log + .message + .as_ref() + .expect("message must exist when exception is not present") + )) + } + }) + .collect(); + + app.data.readarr_data.logs.set_items(log_lines); + app.data.readarr_data.logs.scroll_to_bottom(); + }) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_security_config( &mut self, ) -> Result { diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index 958448cf..a8b4ed60 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::readarr_models::ReadarrSerdeable; use crate::models::servarr_models::{ AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, - SecurityConfig, SystemStatus, Update, + LogResponse, SecurityConfig, SystemStatus, Update, }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; @@ -145,6 +145,92 @@ mod tests { assert_err!(result); } + #[tokio::test] + async fn test_handle_get_readarr_logs_event() { + let logs_response_json = json!({ + "page": 1, + "pageSize": 500, + "sortKey": "time", + "sortDirection": "descending", + "totalRecords": 2, + "records": [ + { + "time": "2023-05-20T21:29:16Z", + "level": "info", + "logger": "TestLogger", + "message": "test message", + "id": 1 + }, + { + "time": "2023-05-20T21:29:16Z", + "level": "fatal", + "logger": "ReadarrError", + "exception": "test exception", + "exceptionType": "Some.Big.Bad.Exception", + "id": 2 + } + ] + }); + let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); + let expected_logs = vec![ + HorizontallyScrollableText::from( + "2023-05-20 21:29:16 UTC|FATAL|ReadarrError|Some.Big.Bad.Exception|test exception", + ), + HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(logs_response_json) + .query("pageSize=500&sortDirection=descending&sortKey=time") + .build_for(ReadarrEvent::GetLogs(500)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetLogs(500)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::LogResponse(logs) = result.unwrap() else { + panic!("Expected LogResponse") + }; + + assert_eq!(logs, response); + assert_eq!(app.lock().await.data.readarr_data.logs.items, expected_logs); + assert_str_eq!( + app + .lock() + .await + .data + .readarr_data + .logs + .current_selection() + .text, + "2023-05-20 21:29:16 UTC|INFO|TestLogger|test message" + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_logs_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .query("pageSize=500&sortDirection=descending&sortKey=time") + .build_for(ReadarrEvent::GetLogs(500)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetLogs(500)) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.logs); + } + #[tokio::test] async fn test_handle_get_readarr_security_config_event() { let (mock, app, _server) = MockServarrApi::get() From bd0274411c7e7706cab1a275164ac67d0d0a57e4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 21:36:37 -0600 Subject: [PATCH 012/170] feat(readarr): fetch Readarr tasks and expose them on the CLI Adds ReadarrEvent::GetTasks backed by get_readarr_tasks, populating the Readarr tasks table, and surfaces it as 'readarr list tasks'. --- src/cli/readarr/list_command_handler.rs | 9 +++ src/cli/readarr/list_command_handler_tests.rs | 24 +++++- src/network/readarr_network/mod.rs | 3 + .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 18 +++++ .../system/readarr_system_network_tests.rs | 79 ++++++++++++++++++- 6 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 85909d48..1eac67c9 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -29,6 +29,8 @@ pub enum ReadarrListCommand { )] output_in_log_format: bool, }, + #[command(about = "List all Readarr tasks")] + Tasks, #[command(about = "List all Readarr updates")] Updates, } @@ -84,6 +86,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman serde_json::to_string_pretty(&logs)? } } + ReadarrListCommand::Tasks => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetTasks.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Updates => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 3a9db21e..b0e7494b 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -25,7 +25,7 @@ mod tests { #[rstest] fn test_list_commands_have_no_arg_requirements( - #[values("disk-space", "updates")] subcommand: &str, + #[values("disk-space", "tasks", "updates")] subcommand: &str, ) { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", subcommand]); @@ -161,6 +161,28 @@ mod tests { assert_str_eq!(result.unwrap(), "[\n \"readarr log line\"\n]"); } + #[tokio::test] + async fn test_handle_list_tasks_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetTasks.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_tasks_command = ReadarrListCommand::Tasks; + + let result = ReadarrListCommandHandler::with(&app_arc, list_tasks_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_updates_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 91d407fc..6e30e081 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -18,6 +18,7 @@ pub enum ReadarrEvent { GetLogs(u64), GetSecurityConfig, GetStatus, + GetTasks, GetUpdates, HealthCheck, } @@ -30,6 +31,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetStatus => "/system/status", + ReadarrEvent::GetTasks => "/system/task", ReadarrEvent::GetUpdates => "/update", } } @@ -64,6 +66,7 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), + ReadarrEvent::GetTasks => self.get_readarr_tasks().await.map(ReadarrSerdeable::from), ReadarrEvent::GetUpdates => self.get_readarr_updates().await.map(ReadarrSerdeable::from), ReadarrEvent::HealthCheck => self .get_readarr_healthcheck() diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 4b1cf495..96ecf18d 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -12,6 +12,7 @@ mod tests { #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetStatus, "/system/status")] + #[case(ReadarrEvent::GetTasks, "/system/task")] #[case(ReadarrEvent::GetUpdates, "/update")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { assert_str_eq!(event.resource(), expected_uri); diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index 4b960ec6..4f28dcb2 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,3 +1,4 @@ +use crate::models::readarr_models::ReadarrTask; use crate::models::servarr_models::{ DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update, }; @@ -134,6 +135,23 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_readarr_tasks( + &mut self, + ) -> Result> { + info!("Fetching Readarr tasks"); + let event = ReadarrEvent::GetTasks; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { + app.data.readarr_data.tasks.set_items(tasks_vec); + }) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_updates( &mut self, ) -> Result> { diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index a8b4ed60..5a50a7c2 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::models::HorizontallyScrollableText; - use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTask, ReadarrTaskName}; use crate::models::servarr_models::{ AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update, @@ -337,6 +337,83 @@ mod tests { assert_is_empty!(app.lock().await.data.readarr_data.version); } + #[tokio::test] + async fn test_handle_get_readarr_tasks_event() { + let tasks_json = json!([{ + "name": "Application Update Check", + "taskName": "ApplicationUpdateCheck", + "interval": 360, + "lastExecution": "2023-05-20T21:29:16Z", + "lastDuration": "00:00:00.2293467", + "nextExecution": "2023-05-20T21:29:16Z", + }, + { + "name": "Refresh Author", + "taskName": "RefreshAuthor", + "interval": 10080, + "lastExecution": "2023-05-20T21:29:16Z", + "lastDuration": "00:00:03.7211204", + "nextExecution": "2023-05-20T21:29:16Z", + }]); + let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_tasks = vec![ + ReadarrTask { + name: "Application Update Check".to_owned(), + task_name: ReadarrTaskName::ApplicationUpdateCheck, + interval: 360, + last_execution: timestamp, + last_duration: "00:00:00.2293467".to_owned(), + next_execution: timestamp, + }, + ReadarrTask { + name: "Refresh Author".to_owned(), + task_name: ReadarrTaskName::RefreshAuthor, + interval: 10080, + last_execution: timestamp, + last_duration: "00:00:03.7211204".to_owned(), + next_execution: timestamp, + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(tasks_json) + .build_for(ReadarrEvent::GetTasks) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTasks).await; + + mock.assert_async().await; + + let ReadarrSerdeable::Tasks(tasks) = result.unwrap() else { + panic!("Expected Tasks") + }; + + assert_eq!(tasks, response); + assert_eq!( + app.lock().await.data.readarr_data.tasks.items, + expected_tasks + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_tasks_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetTasks) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTasks).await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.tasks); + } + #[tokio::test] async fn test_handle_get_readarr_updates_event() { let updates_json = json!([{ From ab3992c372ba90401d162300579c0769e277062b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 21:49:28 -0600 Subject: [PATCH 013/170] feat(readarr): trigger Readarr tasks and expose the command on the CLI The task name is sent as the shared CommandBody, matching the Radarr, Sonarr, and Lidarr implementations. The response is returned verbatim and no app state is mutated. --- src/cli/readarr/mod.rs | 19 ++++ src/cli/readarr/readarr_command_tests.rs | 86 +++++++++++++++++++ src/network/readarr_network/mod.rs | 8 +- .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 24 +++++- .../system/readarr_system_network_tests.rs | 47 ++++++++++ 6 files changed, 182 insertions(+), 3 deletions(-) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 1b8ac8f5..0fdafc66 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -7,6 +7,8 @@ use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use tokio::sync::Mutex; use super::{CliCommandHandler, Command}; +use crate::models::readarr_models::ReadarrTaskName; +use crate::network::readarr_network::ReadarrEvent; use crate::{app::App, network::NetworkTrait}; mod get_command_handler; @@ -28,6 +30,16 @@ pub enum ReadarrCommand { about = "Commands to list attributes from your Readarr instance" )] List(ReadarrListCommand), + #[command(about = "Start the specified Readarr task")] + StartTask { + #[arg( + long, + help = "The name of the task to trigger", + value_enum, + required = true + )] + task_name: ReadarrTaskName, + }, } impl From for Command { @@ -67,6 +79,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::StartTask { task_name } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::StartTask(task_name).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index f07a7d9b..00cb2582 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -2,6 +2,8 @@ mod tests { mod cli { use clap::CommandFactory; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; use crate::Cli; @@ -11,5 +13,89 @@ mod tests { assert_err!(&result); } + + #[test] + fn test_start_task_requires_task_name() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "start-task"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_start_task_task_name_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "start-task", + "--task-name", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_start_task_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "start-task", + "--task-name", + "check-health", + ]); + + assert_ok!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; + use crate::{ + app::App, + cli::{ + CliCommandHandler, + readarr::{ReadarrCliHandler, ReadarrCommand}, + }, + models::Serdeable, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_start_task_command() { + let expected_task_name = ReadarrTaskName::CheckHealth; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::StartTask(expected_task_name).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let start_task_command = ReadarrCommand::StartTask { + task_name: ReadarrTaskName::CheckHealth, + }; + + let result = ReadarrCliHandler::with(&app_arc, start_task_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 6e30e081..3c0b233a 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -2,7 +2,7 @@ use anyhow::Result; use log::info; use super::{NetworkEvent, NetworkResource}; -use crate::models::readarr_models::ReadarrSerdeable; +use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; use crate::network::{Network, RequestMethod}; mod system; @@ -21,11 +21,13 @@ pub enum ReadarrEvent { GetTasks, GetUpdates, HealthCheck, + StartTask(ReadarrTaskName), } impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { + ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", @@ -72,6 +74,10 @@ impl Network<'_, '_> { .get_readarr_healthcheck() .await .map(ReadarrSerdeable::from), + ReadarrEvent::StartTask(task_name) => self + .start_readarr_task(task_name) + .await + .map(ReadarrSerdeable::from), } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 96ecf18d..d845e79f 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -6,6 +6,7 @@ mod tests { use rstest::rstest; #[rstest] + #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index 4f28dcb2..a20bdac8 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,6 +1,6 @@ -use crate::models::readarr_models::ReadarrTask; +use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; use crate::models::servarr_models::{ - DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update, + CommandBody, DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update, }; use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::readarr_network::ReadarrEvent; @@ -8,6 +8,7 @@ use crate::network::{Network, RequestMethod}; use anyhow::Result; use indoc::formatdoc; use log::info; +use serde_json::Value; #[cfg(test)] #[path = "readarr_system_network_tests.rs"] @@ -231,4 +232,23 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn start_readarr_task( + &mut self, + task: ReadarrTaskName, + ) -> Result { + let event = ReadarrEvent::StartTask(task); + let task_name = task.to_string(); + info!("Starting Readarr task: {task_name}"); + + let body = CommandBody { name: task_name }; + + 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/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index 5a50a7c2..871c9b92 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -514,4 +514,51 @@ mod tests { assert_err!(result); assert_is_empty!(app.lock().await.data.readarr_data.updates.get_text()); } + + #[tokio::test] + async fn test_handle_start_readarr_task_event() { + let response = json!({ "test": "test" }); + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "CheckHealth" + })) + .returns(response.clone()) + .build_for(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Value(value) = result.unwrap() else { + panic!("Expected Value") + }; + + assert_eq!(value, response); + } + + #[tokio::test] + async fn test_handle_start_readarr_task_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "CheckHealth" + })) + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + + mock.assert_async().await; + assert_err!(result); + } } From 44d147ce375dbc44183ce2aa4ddeb5878356da51 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 21:52:18 -0600 Subject: [PATCH 014/170] feat(readarr): fetch Readarr queued events and expose them on the CLI An empty array is a valid response when nothing is queued, so the empty case is covered by its own test. --- src/cli/readarr/list_command_handler.rs | 9 ++ src/cli/readarr/list_command_handler_tests.rs | 25 ++++- src/network/readarr_network/mod.rs | 7 +- .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 23 ++++- .../system/readarr_system_network_tests.rs | 92 ++++++++++++++++++- 6 files changed, 153 insertions(+), 4 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 1eac67c9..a975007a 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -29,6 +29,8 @@ pub enum ReadarrListCommand { )] output_in_log_format: bool, }, + #[command(about = "List all queued events")] + QueuedEvents, #[command(about = "List all Readarr tasks")] Tasks, #[command(about = "List all Readarr updates")] @@ -86,6 +88,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman serde_json::to_string_pretty(&logs)? } } + ReadarrListCommand::QueuedEvents => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetQueuedEvents.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Tasks => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index b0e7494b..dd4951a7 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -25,7 +25,7 @@ mod tests { #[rstest] fn test_list_commands_have_no_arg_requirements( - #[values("disk-space", "tasks", "updates")] subcommand: &str, + #[values("disk-space", "queued-events", "tasks", "updates")] subcommand: &str, ) { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", subcommand]); @@ -161,6 +161,29 @@ mod tests { assert_str_eq!(result.unwrap(), "[\n \"readarr log line\"\n]"); } + #[tokio::test] + async fn test_handle_list_queued_events_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetQueuedEvents.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_queued_events_command = ReadarrListCommand::QueuedEvents; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_queued_events_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_tasks_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 3c0b233a..1008cc8c 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -16,6 +16,7 @@ pub enum ReadarrEvent { GetDiskSpace, GetHostConfig, GetLogs(u64), + GetQueuedEvents, GetSecurityConfig, GetStatus, GetTasks, @@ -27,7 +28,7 @@ pub enum ReadarrEvent { impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { - ReadarrEvent::StartTask(_) => "/command", + ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", @@ -63,6 +64,10 @@ impl Network<'_, '_> { .get_readarr_logs(events) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetQueuedEvents => self + .get_queued_readarr_events() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetSecurityConfig => self .get_readarr_security_config() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d845e79f..46da9ccd 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -6,6 +6,7 @@ mod tests { use rstest::rstest; #[rstest] + #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index a20bdac8..694e2df4 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,6 +1,6 @@ use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; use crate::models::servarr_models::{ - CommandBody, DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update, + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::readarr_network::ReadarrEvent; @@ -15,6 +15,27 @@ use serde_json::Value; mod readarr_system_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_queued_readarr_events( + &mut self, + ) -> Result> { + info!("Fetching Readarr queued events"); + let event = ReadarrEvent::GetQueuedEvents; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |queued_events_vec, mut app| { + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec); + }) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_diskspace( &mut self, ) -> Result> { diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index 871c9b92..a95170dc 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTask, ReadarrTaskName}; use crate::models::servarr_models::{ AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, - LogResponse, SecurityConfig, SystemStatus, Update, + LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; @@ -13,6 +13,96 @@ mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::json; + #[tokio::test] + async fn test_handle_get_queued_readarr_events_event() { + let queued_events_json = json!([{ + "name": "CheckHealth", + "commandName": "Check Health", + "status": "completed", + "queued": "2023-05-20T21:29:16Z", + "started": "2023-05-20T21:29:16Z", + "ended": "2023-05-20T21:29:16Z", + "duration": "00:00:00.3524068", + "trigger": "manual", + }]); + let response: Vec = serde_json::from_value(queued_events_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_event = QueueEvent { + name: "CheckHealth".to_owned(), + command_name: "Check Health".to_owned(), + status: "completed".to_owned(), + queued: timestamp, + started: Some(timestamp), + ended: Some(timestamp), + duration: Some("00:00:00.3524068".to_owned()), + trigger: "manual".to_owned(), + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(queued_events_json) + .build_for(ReadarrEvent::GetQueuedEvents) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQueuedEvents) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::QueueEvents(events) = result.unwrap() else { + panic!("Expected QueueEvents") + }; + + assert_eq!(events, response); + assert_eq!( + app.lock().await.data.readarr_data.queued_events.items, + vec![expected_event] + ); + } + + #[tokio::test] + async fn test_handle_get_queued_readarr_events_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetQueuedEvents) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQueuedEvents) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::QueueEvents(events) = result.unwrap() else { + panic!("Expected QueueEvents") + }; + + assert_is_empty!(events); + assert_is_empty!(app.lock().await.data.readarr_data.queued_events); + } + + #[tokio::test] + async fn test_handle_get_queued_readarr_events_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetQueuedEvents) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQueuedEvents) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.queued_events); + } + #[tokio::test] async fn test_handle_get_readarr_diskspace_event() { let (mock, app, _server) = MockServarrApi::get() From 4a7cf922e82016d410daccfdd44172b57c6c884a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 22:02:34 -0600 Subject: [PATCH 015/170] feat(readarr): fetch Readarr quality profiles and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 9 ++ src/cli/readarr/list_command_handler_tests.rs | 26 +++++- src/network/readarr_network/mod.rs | 25 +++++ .../readarr_network/readarr_network_tests.rs | 91 +++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index a975007a..fb7c2383 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -29,6 +29,8 @@ pub enum ReadarrListCommand { )] output_in_log_format: bool, }, + #[command(about = "List all Readarr quality profiles")] + QualityProfiles, #[command(about = "List all queued events")] QueuedEvents, #[command(about = "List all Readarr tasks")] @@ -88,6 +90,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman serde_json::to_string_pretty(&logs)? } } + ReadarrListCommand::QualityProfiles => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetQualityProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::QueuedEvents => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index dd4951a7..bd2bb76b 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -25,7 +25,8 @@ mod tests { #[rstest] fn test_list_commands_have_no_arg_requirements( - #[values("disk-space", "queued-events", "tasks", "updates")] subcommand: &str, + #[values("disk-space", "quality-profiles", "queued-events", "tasks", "updates")] + subcommand: &str, ) { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", subcommand]); @@ -161,6 +162,29 @@ mod tests { assert_str_eq!(result.unwrap(), "[\n \"readarr log line\"\n]"); } + #[tokio::test] + async fn test_handle_list_quality_profiles_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetQualityProfiles.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_quality_profiles_command = ReadarrListCommand::QualityProfiles; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_quality_profiles_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_queued_events_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 1008cc8c..1b1b0cb8 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -3,6 +3,7 @@ use log::info; use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; +use crate::models::servarr_models::QualityProfile; use crate::network::{Network, RequestMethod}; mod system; @@ -16,6 +17,7 @@ pub enum ReadarrEvent { GetDiskSpace, GetHostConfig, GetLogs(u64), + GetQualityProfiles, GetQueuedEvents, GetSecurityConfig, GetStatus, @@ -33,6 +35,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetLogs(_) => "/log", + ReadarrEvent::GetQualityProfiles => "/qualityprofile", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", ReadarrEvent::GetUpdates => "/update", @@ -64,6 +67,10 @@ impl Network<'_, '_> { .get_readarr_logs(events) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetQualityProfiles => self + .get_readarr_quality_profiles() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetQueuedEvents => self .get_queued_readarr_events() .await @@ -100,4 +107,22 @@ impl Network<'_, '_> { .handle_request::<(), ()>(request_props, |_, _| ()) .await } + + async fn get_readarr_quality_profiles(&mut self) -> Result> { + info!("Fetching Readarr quality profiles"); + let event = ReadarrEvent::GetQualityProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { + app.data.readarr_data.quality_profile_map = quality_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 46da9ccd..732b375c 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -1,9 +1,12 @@ #[cfg(test)] mod tests { + use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::servarr_models::QualityProfile; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; + use serde_json::json; #[rstest] #[case(ReadarrEvent::GetQueuedEvents, "/command")] @@ -13,6 +16,7 @@ mod tests { #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetLogs(500), "/log")] + #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] #[case(ReadarrEvent::GetUpdates, "/update")] @@ -59,4 +63,91 @@ mod tests { mock.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_get_quality_profiles_event() { + let quality_profiles_json = json!([ + { + "id": 1, + "name": "eBook" + }, + { + "id": 2, + "name": "Spoken" + } + ]); + let response: Vec = + serde_json::from_value(quality_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(quality_profiles_json) + .build_for(ReadarrEvent::GetQualityProfiles) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQualityProfiles) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::QualityProfiles(quality_profiles) = result.unwrap() else { + panic!("Expected QualityProfiles"); + }; + + assert_eq!(quality_profiles, response); + + let app = app.lock().await; + + assert_some_eq_x!( + app.data.readarr_data.quality_profile_map.get_by_left(&1), + &"eBook".to_owned() + ); + assert_some_eq_x!( + app.data.readarr_data.quality_profile_map.get_by_left(&2), + &"Spoken".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_get_quality_profiles_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetQualityProfiles) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQualityProfiles) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::QualityProfiles(quality_profiles) = result.unwrap() else { + panic!("Expected QualityProfiles"); + }; + + assert_is_empty!(quality_profiles); + assert_is_empty!(app.lock().await.data.readarr_data.quality_profile_map); + } + + #[tokio::test] + async fn test_handle_get_quality_profiles_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetQualityProfiles) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQualityProfiles) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.quality_profile_map); + } } From de41d6fef76ec50a0ca316e78e0bcb4b63ce96c3 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 22:04:38 -0600 Subject: [PATCH 016/170] feat(readarr): fetch Readarr metadata profiles and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 9 ++ src/cli/readarr/list_command_handler_tests.rs | 35 +++++++- src/network/readarr_network/mod.rs | 26 +++++- .../readarr_network/readarr_network_tests.rs | 90 ++++++++++++++++++- 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index fb7c2383..7b2f570f 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -29,6 +29,8 @@ pub enum ReadarrListCommand { )] output_in_log_format: bool, }, + #[command(about = "List all Readarr metadata profiles")] + MetadataProfiles, #[command(about = "List all Readarr quality profiles")] QualityProfiles, #[command(about = "List all queued events")] @@ -90,6 +92,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman serde_json::to_string_pretty(&logs)? } } + ReadarrListCommand::MetadataProfiles => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetMetadataProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::QualityProfiles => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index bd2bb76b..2e32c43d 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -25,7 +25,14 @@ mod tests { #[rstest] fn test_list_commands_have_no_arg_requirements( - #[values("disk-space", "quality-profiles", "queued-events", "tasks", "updates")] + #[values( + "disk-space", + "metadata-profiles", + "quality-profiles", + "queued-events", + "tasks", + "updates" + )] subcommand: &str, ) { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", subcommand]); @@ -162,6 +169,32 @@ mod tests { assert_str_eq!(result.unwrap(), "[\n \"readarr log line\"\n]"); } + #[tokio::test] + async fn test_handle_list_metadata_profiles_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetMetadataProfiles.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_metadata_profiles_command = ReadarrListCommand::MetadataProfiles; + + let result = ReadarrListCommandHandler::with( + &app_arc, + list_metadata_profiles_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_quality_profiles_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 1b1b0cb8..5ebf41c2 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -3,7 +3,7 @@ use log::info; use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; -use crate::models::servarr_models::QualityProfile; +use crate::models::servarr_models::{MetadataProfile, QualityProfile}; use crate::network::{Network, RequestMethod}; mod system; @@ -17,6 +17,7 @@ pub enum ReadarrEvent { GetDiskSpace, GetHostConfig, GetLogs(u64), + GetMetadataProfiles, GetQualityProfiles, GetQueuedEvents, GetSecurityConfig, @@ -35,6 +36,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetLogs(_) => "/log", + ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", @@ -67,6 +69,10 @@ impl Network<'_, '_> { .get_readarr_logs(events) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetMetadataProfiles => self + .get_readarr_metadata_profiles() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetQualityProfiles => self .get_readarr_quality_profiles() .await @@ -108,6 +114,24 @@ impl Network<'_, '_> { .await } + async fn get_readarr_metadata_profiles(&mut self) -> Result> { + info!("Fetching Readarr metadata profiles"); + let event = ReadarrEvent::GetMetadataProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |metadata_profiles, mut app| { + app.data.readarr_data.metadata_profile_map = metadata_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + async fn get_readarr_quality_profiles(&mut self) -> Result> { info!("Fetching Readarr quality profiles"); let event = ReadarrEvent::GetQualityProfiles; diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 732b375c..6bb3b8a9 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::ReadarrSerdeable; - use crate::models::servarr_models::QualityProfile; + use crate::models::servarr_models::{MetadataProfile, QualityProfile}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; use pretty_assertions::{assert_eq, assert_str_eq}; @@ -16,6 +16,7 @@ mod tests { #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetLogs(500), "/log")] + #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] @@ -64,6 +65,93 @@ mod tests { assert_err!(result); } + #[tokio::test] + async fn test_handle_get_metadata_profiles_event() { + let metadata_profiles_json = json!([ + { + "id": 1, + "name": "Standard" + }, + { + "id": 2, + "name": "None" + } + ]); + let response: Vec = + serde_json::from_value(metadata_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(metadata_profiles_json) + .build_for(ReadarrEvent::GetMetadataProfiles) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetMetadataProfiles) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::MetadataProfiles(metadata_profiles) = result.unwrap() else { + panic!("Expected MetadataProfiles"); + }; + + assert_eq!(metadata_profiles, response); + + let app = app.lock().await; + + assert_some_eq_x!( + app.data.readarr_data.metadata_profile_map.get_by_left(&1), + &"Standard".to_owned() + ); + assert_some_eq_x!( + app.data.readarr_data.metadata_profile_map.get_by_left(&2), + &"None".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_get_metadata_profiles_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetMetadataProfiles) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetMetadataProfiles) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::MetadataProfiles(metadata_profiles) = result.unwrap() else { + panic!("Expected MetadataProfiles"); + }; + + assert_is_empty!(metadata_profiles); + assert_is_empty!(app.lock().await.data.readarr_data.metadata_profile_map); + } + + #[tokio::test] + async fn test_handle_get_metadata_profiles_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetMetadataProfiles) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetMetadataProfiles) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.metadata_profile_map); + } + #[tokio::test] async fn test_handle_get_quality_profiles_event() { let quality_profiles_json = json!([ From 1d8ccd7ff6684085e3819fe39ce2945b317f7126 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 22:06:23 -0600 Subject: [PATCH 017/170] feat(readarr): fetch Readarr tags and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 9 +++ src/cli/readarr/list_command_handler_tests.rs | 23 ++++++ src/network/readarr_network/mod.rs | 23 +++++- .../readarr_network/readarr_network_tests.rs | 72 ++++++++++++++++++- 4 files changed, 125 insertions(+), 2 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 7b2f570f..82c0777f 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -35,6 +35,8 @@ pub enum ReadarrListCommand { QualityProfiles, #[command(about = "List all queued events")] QueuedEvents, + #[command(about = "List all Readarr tags")] + Tags, #[command(about = "List all Readarr tasks")] Tasks, #[command(about = "List all Readarr updates")] @@ -113,6 +115,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Tags => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetTags.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Tasks => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 2e32c43d..4b0e45c9 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -30,6 +30,7 @@ mod tests { "metadata-profiles", "quality-profiles", "queued-events", + "tags", "tasks", "updates" )] @@ -241,6 +242,28 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_tags_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetTags.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_tags_command = ReadarrListCommand::Tags; + + let result = ReadarrListCommandHandler::with(&app_arc, list_tags_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_tasks_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 5ebf41c2..91b15cd0 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -3,7 +3,7 @@ use log::info; use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; -use crate::models::servarr_models::{MetadataProfile, QualityProfile}; +use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; mod system; @@ -22,6 +22,7 @@ pub enum ReadarrEvent { GetQueuedEvents, GetSecurityConfig, GetStatus, + GetTags, GetTasks, GetUpdates, HealthCheck, @@ -40,6 +41,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetQualityProfiles => "/qualityprofile", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", + ReadarrEvent::GetTags => "/tag", ReadarrEvent::GetUpdates => "/update", } } @@ -86,6 +88,7 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), + ReadarrEvent::GetTags => self.get_readarr_tags().await.map(ReadarrSerdeable::from), ReadarrEvent::GetTasks => self.get_readarr_tasks().await.map(ReadarrSerdeable::from), ReadarrEvent::GetUpdates => self.get_readarr_updates().await.map(ReadarrSerdeable::from), ReadarrEvent::HealthCheck => self @@ -149,4 +152,22 @@ impl Network<'_, '_> { }) .await } + + async fn get_readarr_tags(&mut self) -> Result> { + info!("Fetching Readarr tags"); + let event = ReadarrEvent::GetTags; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { + app.data.readarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id, tag.label)) + .collect(); + }) + .await + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 6bb3b8a9..91d8d093 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::ReadarrSerdeable; - use crate::models::servarr_models::{MetadataProfile, QualityProfile}; + use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; use pretty_assertions::{assert_eq, assert_str_eq}; @@ -20,6 +20,7 @@ mod tests { #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] + #[case(ReadarrEvent::GetTags, "/tag")] #[case(ReadarrEvent::GetUpdates, "/update")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { assert_str_eq!(event.resource(), expected_uri); @@ -238,4 +239,73 @@ mod tests { assert_err!(result); assert_is_empty!(app.lock().await.data.readarr_data.quality_profile_map); } + + #[tokio::test] + async fn test_handle_get_tags_event() { + let tags_json = json!([ + { + "id": 1, + "label": "huntarr-missing" + } + ]); + let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(tags_json) + .build_for(ReadarrEvent::GetTags) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTags).await; + + mock.assert_async().await; + + let ReadarrSerdeable::Tags(tags) = result.unwrap() else { + panic!("Expected Tags"); + }; + + assert_eq!(tags, response); + assert_some_eq_x!( + app.lock().await.data.readarr_data.tags_map.get_by_left(&1), + &"huntarr-missing".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_get_tags_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetTags) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTags).await; + + mock.assert_async().await; + + let ReadarrSerdeable::Tags(tags) = result.unwrap() else { + panic!("Expected Tags"); + }; + + assert_is_empty!(tags); + assert_is_empty!(app.lock().await.data.readarr_data.tags_map); + } + + #[tokio::test] + async fn test_handle_get_tags_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetTags) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTags).await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.tags_map); + } } From 0cc49089b66e14a1d139312fa8366da8cde342e9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 22:16:38 -0600 Subject: [PATCH 018/170] test(readarr): seed profile and tag maps so empty and failure assertions can fail The empty-response and failure tests asserted the maps were empty against a default state that was already empty, so they passed even with the fetcher body removed. Seeding a stale entry first makes the empty-response tests prove the fetcher replaces rather than merges, and the failure tests prove the update closure never runs on a non-success response. --- .../readarr_network/readarr_network_tests.rs | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 91d8d093..2f34d13a 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -4,6 +4,7 @@ mod tests { use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; + use bimap::BiMap; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::json; @@ -117,7 +118,12 @@ mod tests { .returns(json!([])) .build_for(ReadarrEvent::GetMetadataProfiles) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(99i64, "Stale Profile".to_owned())]); + } let mut network = test_network(&app); let result = network @@ -136,12 +142,17 @@ mod tests { #[tokio::test] async fn test_handle_get_metadata_profiles_event_failure() { + let seeded_map = BiMap::from_iter([(99i64, "Stale Profile".to_owned())]); let (mock, app, _server) = MockServarrApi::get() .returns(json!({})) .status(500) .build_for(ReadarrEvent::GetMetadataProfiles) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.metadata_profile_map = seeded_map.clone(); + } let mut network = test_network(&app); let result = network @@ -150,7 +161,10 @@ mod tests { mock.assert_async().await; assert_err!(result); - assert_is_empty!(app.lock().await.data.readarr_data.metadata_profile_map); + assert_eq!( + app.lock().await.data.readarr_data.metadata_profile_map, + seeded_map + ); } #[tokio::test] @@ -204,7 +218,12 @@ mod tests { .returns(json!([])) .build_for(ReadarrEvent::GetQualityProfiles) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(99i64, "Stale Profile".to_owned())]); + } let mut network = test_network(&app); let result = network @@ -223,12 +242,17 @@ mod tests { #[tokio::test] async fn test_handle_get_quality_profiles_event_failure() { + let seeded_map = BiMap::from_iter([(99i64, "Stale Profile".to_owned())]); let (mock, app, _server) = MockServarrApi::get() .returns(json!({})) .status(500) .build_for(ReadarrEvent::GetQualityProfiles) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.quality_profile_map = seeded_map.clone(); + } let mut network = test_network(&app); let result = network @@ -237,7 +261,10 @@ mod tests { mock.assert_async().await; assert_err!(result); - assert_is_empty!(app.lock().await.data.readarr_data.quality_profile_map); + assert_eq!( + app.lock().await.data.readarr_data.quality_profile_map, + seeded_map + ); } #[tokio::test] @@ -277,7 +304,11 @@ mod tests { .returns(json!([])) .build_for(ReadarrEvent::GetTags) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = BiMap::from_iter([(99i64, "stale-tag".to_owned())]); + } let mut network = test_network(&app); let result = network.handle_readarr_event(ReadarrEvent::GetTags).await; @@ -294,18 +325,23 @@ mod tests { #[tokio::test] async fn test_handle_get_tags_event_failure() { + let seeded_map = BiMap::from_iter([(99i64, "stale-tag".to_owned())]); let (mock, app, _server) = MockServarrApi::get() .returns(json!({})) .status(500) .build_for(ReadarrEvent::GetTags) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = seeded_map.clone(); + } let mut network = test_network(&app); let result = network.handle_readarr_event(ReadarrEvent::GetTags).await; mock.assert_async().await; assert_err!(result); - assert_is_empty!(app.lock().await.data.readarr_data.tags_map); + assert_eq!(app.lock().await.data.readarr_data.tags_map, seeded_map); } } From ce8c51b02f0cb6ec26eecc635d1f9d851c9b9016 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 22:24:57 -0600 Subject: [PATCH 019/170] test(readarr): seed system app data so empty and failure assertions can fail The system network tests asserted that queued events, disk space, logs, version, tasks, and updates were empty against a default state that was already empty. Those assertions passed even with the fetcher body removed, so they reported coverage that did not exist. Seeding a stale value first makes the empty response test prove the fetcher replaces rather than merges, and makes the failure tests prove the update closure never runs on a non-success response by pinning the exact seeded value. The assertion on the deserialized payload in the empty response test is left alone: it reads the response body rather than app state, so it was never vacuous. --- .../system/readarr_system_network_tests.rs | 97 ++++++++++++++++--- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index a95170dc..1f713b36 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -1,11 +1,11 @@ #[cfg(test)] mod tests { - use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTask, ReadarrTaskName}; use crate::models::servarr_models::{ AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; + use crate::models::{HorizontallyScrollableText, ScrollableText}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use chrono::DateTime; @@ -67,7 +67,18 @@ mod tests { .returns(json!([])) .build_for(ReadarrEvent::GetQueuedEvents) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .queued_events + .set_items(vec![QueueEvent { + name: "StaleEvent".to_owned(), + ..QueueEvent::default() + }]); + } let mut network = test_network(&app); let result = network @@ -86,12 +97,24 @@ mod tests { #[tokio::test] async fn test_handle_get_queued_readarr_events_event_failure() { + let seeded_event = QueueEvent { + name: "StaleEvent".to_owned(), + ..QueueEvent::default() + }; let (mock, app, _server) = MockServarrApi::get() .returns(json!({})) .status(500) .build_for(ReadarrEvent::GetQueuedEvents) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .queued_events + .set_items(vec![seeded_event.clone()]); + } let mut network = test_network(&app); let result = network @@ -100,7 +123,10 @@ mod tests { mock.assert_async().await; assert_err!(result); - assert_is_empty!(app.lock().await.data.readarr_data.queued_events); + assert_eq!( + app.lock().await.data.readarr_data.queued_events.items, + vec![seeded_event] + ); } #[tokio::test] @@ -155,12 +181,21 @@ mod tests { #[tokio::test] async fn test_handle_get_readarr_diskspace_event_failure() { + let seeded_disk_space = vec![DiskSpace { + path: Some("/stale".to_owned()), + free_space: 1, + total_space: 2, + }]; let (mock, app, _server) = MockServarrApi::get() .returns(json!({})) .status(500) .build_for(ReadarrEvent::GetDiskSpace) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.disk_space_vec = seeded_disk_space.clone(); + } let mut network = test_network(&app); let result = network @@ -169,7 +204,10 @@ mod tests { mock.assert_async().await; assert_err!(result); - assert_is_empty!(app.lock().await.data.readarr_data.disk_space_vec); + assert_eq!( + app.lock().await.data.readarr_data.disk_space_vec, + seeded_disk_space + ); } #[tokio::test] @@ -303,13 +341,18 @@ mod tests { #[tokio::test] async fn test_handle_get_readarr_logs_event_failure() { + let seeded_logs = vec![HorizontallyScrollableText::from("stale log entry")]; let (mock, app, _server) = MockServarrApi::get() .returns(json!({})) .status(500) .query("pageSize=500&sortDirection=descending&sortKey=time") .build_for(ReadarrEvent::GetLogs(500)) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.logs.set_items(seeded_logs.clone()); + } let mut network = test_network(&app); let result = network @@ -318,7 +361,7 @@ mod tests { mock.assert_async().await; assert_err!(result); - assert_is_empty!(app.lock().await.data.readarr_data.logs); + assert_eq!(app.lock().await.data.readarr_data.logs.items, seeded_logs); } #[tokio::test] @@ -417,14 +460,18 @@ mod tests { .status(500) .build_for(ReadarrEvent::GetStatus) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.version = "stale version".to_owned(); + } let mut network = test_network(&app); let result = network.handle_readarr_event(ReadarrEvent::GetStatus).await; mock.assert_async().await; assert_err!(result); - assert_is_empty!(app.lock().await.data.readarr_data.version); + assert_str_eq!(app.lock().await.data.readarr_data.version, "stale version"); } #[tokio::test] @@ -489,19 +536,34 @@ mod tests { #[tokio::test] async fn test_handle_get_readarr_tasks_event_failure() { + let seeded_task = ReadarrTask { + name: "Stale Task".to_owned(), + ..ReadarrTask::default() + }; let (mock, app, _server) = MockServarrApi::get() .returns(json!({})) .status(500) .build_for(ReadarrEvent::GetTasks) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .tasks + .set_items(vec![seeded_task.clone()]); + } let mut network = test_network(&app); let result = network.handle_readarr_event(ReadarrEvent::GetTasks).await; mock.assert_async().await; assert_err!(result); - assert_is_empty!(app.lock().await.data.readarr_data.tasks); + assert_eq!( + app.lock().await.data.readarr_data.tasks.items, + vec![seeded_task] + ); } #[tokio::test] @@ -595,14 +657,21 @@ mod tests { .status(500) .build_for(ReadarrEvent::GetUpdates) .await; - app.lock().await.server_tabs.set_index(3); + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.updates = ScrollableText::with_string("stale updates".to_owned()); + } let mut network = test_network(&app); let result = network.handle_readarr_event(ReadarrEvent::GetUpdates).await; mock.assert_async().await; assert_err!(result); - assert_is_empty!(app.lock().await.data.readarr_data.updates.get_text()); + assert_str_eq!( + app.lock().await.data.readarr_data.updates.get_text(), + "stale updates" + ); } #[tokio::test] From 29186ea03c1217774a5c09dc57f9e438b3897020 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 22:32:16 -0600 Subject: [PATCH 020/170] feat(readarr): fetch Readarr root folders and expose them on the CLI Adds the GetRootFolders event backed by GET /rootfolder, storing the result on the Readarr root folders table, and surfaces it as "managarr readarr list root-folders". Root folders reuse the shared RootFolder model. Readarr omits the unmappedFolders key that the other servarrs return, and the field is optional, so it deserializes to None without changing the shared type. Only the read path is included here. Adding and deleting root folders mutate the library layout, so they are left for separate work. --- src/cli/readarr/list_command_handler.rs | 9 + src/cli/readarr/list_command_handler_tests.rs | 24 +++ src/network/readarr_network/mod.rs | 7 + .../readarr_network/readarr_network_tests.rs | 1 + .../readarr_network/root_folders/mod.rs | 28 +++ .../readarr_root_folders_network_tests.rs | 167 ++++++++++++++++++ 6 files changed, 236 insertions(+) create mode 100644 src/network/readarr_network/root_folders/mod.rs create mode 100644 src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 82c0777f..1723ba16 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -35,6 +35,8 @@ pub enum ReadarrListCommand { QualityProfiles, #[command(about = "List all queued events")] QueuedEvents, + #[command(about = "List all root folders in Readarr")] + RootFolders, #[command(about = "List all Readarr tags")] Tags, #[command(about = "List all Readarr tasks")] @@ -115,6 +117,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::RootFolders => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetRootFolders.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Tags => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 4b0e45c9..14c3e2ee 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -30,6 +30,7 @@ mod tests { "metadata-profiles", "quality-profiles", "queued-events", + "root-folders", "tags", "tasks", "updates" @@ -242,6 +243,29 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_root_folders_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetRootFolders.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_root_folders_command = ReadarrListCommand::RootFolders; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_root_folders_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_tags_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 91b15cd0..d895d059 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -6,6 +6,7 @@ use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; +mod root_folders; mod system; #[cfg(test)] @@ -20,6 +21,7 @@ pub enum ReadarrEvent { GetMetadataProfiles, GetQualityProfiles, GetQueuedEvents, + GetRootFolders, GetSecurityConfig, GetStatus, GetTags, @@ -39,6 +41,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", + ReadarrEvent::GetRootFolders => "/rootfolder", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", ReadarrEvent::GetTags => "/tag", @@ -83,6 +86,10 @@ impl Network<'_, '_> { .get_queued_readarr_events() .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetRootFolders => self + .get_readarr_root_folders() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetSecurityConfig => self .get_readarr_security_config() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 2f34d13a..427f01a1 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -19,6 +19,7 @@ mod tests { #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(ReadarrEvent::GetRootFolders, "/rootfolder")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] #[case(ReadarrEvent::GetTags, "/tag")] diff --git a/src/network/readarr_network/root_folders/mod.rs b/src/network/readarr_network/root_folders/mod.rs new file mode 100644 index 00000000..21c1030c --- /dev/null +++ b/src/network/readarr_network/root_folders/mod.rs @@ -0,0 +1,28 @@ +use crate::models::servarr_models::RootFolder; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; + +#[cfg(test)] +#[path = "readarr_root_folders_network_tests.rs"] +mod readarr_root_folders_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_root_folders( + &mut self, + ) -> Result> { + info!("Fetching Readarr root folders"); + let event = ReadarrEvent::GetRootFolders; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |root_folders, mut app| { + app.data.readarr_data.root_folders.set_items(root_folders); + }) + .await + } +} diff --git a/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs new file mode 100644 index 00000000..b8a696dc --- /dev/null +++ b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs @@ -0,0 +1,167 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::servarr_models::RootFolder; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + use serde_json::json; + + fn stale_root_folder() -> RootFolder { + RootFolder { + id: 99, + path: "/stale".to_owned(), + accessible: false, + free_space: 1, + unmapped_folders: None, + } + } + + #[tokio::test] + async fn test_handle_get_readarr_root_folders_event() { + let root_folders_json = json!([ + { + "name": "Books", + "path": "/nfs/books", + "defaultMetadataProfileId": 1, + "defaultQualityProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [], + "isCalibreLibrary": false, + "port": 8080, + "outputProfile": "default", + "useSsl": false, + "accessible": true, + "freeSpace": 11039514558464i64, + "totalSpace": 117810875334656i64, + "id": 1 + }, + { + "name": "Audiobooks", + "path": "/nfs/audiobooks", + "defaultMetadataProfileId": 1, + "defaultQualityProfileId": 2, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [], + "isCalibreLibrary": false, + "port": 8080, + "outputProfile": "default", + "useSsl": false, + "accessible": true, + "freeSpace": 11039514558464i64, + "totalSpace": 117810875334656i64, + "id": 2 + } + ]); + let response: Vec = serde_json::from_value(root_folders_json.clone()).unwrap(); + let expected_root_folders = vec![ + RootFolder { + id: 1, + path: "/nfs/books".to_owned(), + accessible: true, + free_space: 11039514558464, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/audiobooks".to_owned(), + accessible: true, + free_space: 11039514558464, + unmapped_folders: None, + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(root_folders_json) + .build_for(ReadarrEvent::GetRootFolders) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .root_folders + .set_items(vec![stale_root_folder()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::RootFolders(root_folders) = result.unwrap() else { + panic!("Expected RootFolders") + }; + + assert_eq!(root_folders, response); + assert_eq!( + app.lock().await.data.readarr_data.root_folders.items, + expected_root_folders + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_root_folders_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetRootFolders) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .root_folders + .set_items(vec![stale_root_folder()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::RootFolders(root_folders) = result.unwrap() else { + panic!("Expected RootFolders") + }; + + assert_is_empty!(root_folders); + assert_is_empty!(app.lock().await.data.readarr_data.root_folders); + } + + #[tokio::test] + async fn test_handle_get_readarr_root_folders_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetRootFolders) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .root_folders + .set_items(vec![stale_root_folder()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.root_folders.items, + vec![stale_root_folder()] + ); + } +} From b9fa3b36c012cb1e7bb896f698f3604ac84eb526 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 22:47:41 -0600 Subject: [PATCH 021/170] feat(readarr): add Readarr tags and expose the command on the CLI --- src/cli/readarr/add_command_handler.rs | 65 +++++++++++ src/cli/readarr/add_command_handler_tests.rs | 101 ++++++++++++++++++ src/cli/readarr/mod.rs | 12 +++ src/network/readarr_network/mod.rs | 26 ++++- .../readarr_network/readarr_network_tests.rs | 65 +++++++++++ 5 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/cli/readarr/add_command_handler.rs create mode 100644 src/cli/readarr/add_command_handler_tests.rs diff --git a/src/cli/readarr/add_command_handler.rs b/src/cli/readarr/add_command_handler.rs new file mode 100644 index 00000000..025cf6d6 --- /dev/null +++ b/src/cli/readarr/add_command_handler.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "add_command_handler_tests.rs"] +mod add_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrAddCommand { + #[command(about = "Add new tag")] + Tag { + #[arg(long, help = "The name of the tag to be added", required = true)] + name: String, + }, +} + +impl From for Command { + fn from(value: ReadarrAddCommand) -> Self { + Command::Readarr(ReadarrCommand::Add(value)) + } +} + +pub(super) struct ReadarrAddCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrAddCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: ReadarrAddCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrAddCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrAddCommand::Tag { name } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::AddTag(name).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/add_command_handler_tests.rs b/src/cli/readarr/add_command_handler_tests.rs new file mode 100644 index 00000000..21ac0c34 --- /dev/null +++ b/src/cli/readarr/add_command_handler_tests.rs @@ -0,0 +1,101 @@ +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser, error::ErrorKind}; + + use crate::{ + Cli, + cli::{ + Command, + readarr::{ReadarrCommand, add_command_handler::ReadarrAddCommand}, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_add_command_from() { + let command = ReadarrAddCommand::Tag { + name: String::new(), + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Add(command))); + } + + mod cli { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_add_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "add", "tag"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_tag_success() { + let expected_args = ReadarrAddCommand::Tag { + name: "test".to_owned(), + }; + + let result = Cli::try_parse_from(["managarr", "readarr", "add", "tag", "--name", "test"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type"); + }; + assert_eq!(add_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::readarr::add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; + use crate::models::Serdeable; + use crate::models::readarr_models::ReadarrSerdeable; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_add_tag_command() { + let expected_tag_name = "test".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::AddTag(expected_tag_name.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_tag_command = ReadarrAddCommand::Tag { + name: expected_tag_name, + }; + + let result = ReadarrAddCommandHandler::with(&app_arc, add_tag_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 0fdafc66..e6f421a1 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; use anyhow::Result; use clap::Subcommand; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; @@ -11,6 +12,7 @@ use crate::models::readarr_models::ReadarrTaskName; use crate::network::readarr_network::ReadarrEvent; use crate::{app::App, network::NetworkTrait}; +mod add_command_handler; mod get_command_handler; mod list_command_handler; @@ -20,6 +22,11 @@ mod readarr_command_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrCommand { + #[command( + subcommand, + about = "Commands to add or create new resources within your Readarr instance" + )] + Add(ReadarrAddCommand), #[command( subcommand, about = "Commands to fetch details of the resources in your Readarr instance" @@ -69,6 +76,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, async fn handle(self) -> Result { let result = match self.command { + ReadarrCommand::Add(add_command) => { + ReadarrAddCommandHandler::with(self.app, add_command, self.network) + .handle() + .await? + } ReadarrCommand::Get(get_command) => { ReadarrGetCommandHandler::with(self.app, get_command, self.network) .handle() diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index d895d059..7846c42b 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; use log::info; +use serde_json::{Value, json}; use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; @@ -15,6 +16,7 @@ mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { + AddTag(String), GetDiskSpace, GetHostConfig, GetLogs(u64), @@ -44,7 +46,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetRootFolders => "/rootfolder", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", - ReadarrEvent::GetTags => "/tag", + ReadarrEvent::AddTag(_) | ReadarrEvent::GetTags => "/tag", ReadarrEvent::GetUpdates => "/update", } } @@ -62,6 +64,7 @@ impl Network<'_, '_> { readarr_event: ReadarrEvent, ) -> Result { match readarr_event { + ReadarrEvent::AddTag(tag) => self.add_readarr_tag(tag).await.map(ReadarrSerdeable::from), ReadarrEvent::GetDiskSpace => self .get_readarr_diskspace() .await @@ -177,4 +180,25 @@ impl Network<'_, '_> { }) .await } + + async fn add_readarr_tag(&mut self, tag: String) -> Result { + info!("Adding a new Readarr tag"); + let event = ReadarrEvent::AddTag(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(json!({ "label": tag })), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |tag, mut app| { + app.data.readarr_data.tags_map.insert(tag.id, tag.label); + }) + .await + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 427f01a1..d224694d 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -22,6 +22,7 @@ mod tests { #[case(ReadarrEvent::GetRootFolders, "/rootfolder")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] + #[case(ReadarrEvent::AddTag(String::new()), "/tag")] #[case(ReadarrEvent::GetTags, "/tag")] #[case(ReadarrEvent::GetUpdates, "/update")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { @@ -345,4 +346,68 @@ mod tests { assert_err!(result); assert_eq!(app.lock().await.data.readarr_data.tags_map, seeded_map); } + + #[tokio::test] + async fn test_handle_add_readarr_tag_event() { + let tag_json = json!({ + "id": 2, + "label": "managarr-verify" + }); + let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "label": "managarr-verify" })) + .returns(tag_json) + .build_for(ReadarrEvent::AddTag("managarr-verify".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = BiMap::from_iter([(1i64, "huntarr-missing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddTag("managarr-verify".to_owned())) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Tag(tag) = result.unwrap() else { + panic!("Expected Tag"); + }; + + assert_eq!(tag, response); + assert_eq!( + app.lock().await.data.readarr_data.tags_map, + BiMap::from_iter([ + (1i64, "huntarr-missing".to_owned()), + (2i64, "managarr-verify".to_owned()) + ]) + ); + } + + #[tokio::test] + async fn test_handle_add_readarr_tag_event_failure() { + let seeded_map = BiMap::from_iter([(1i64, "huntarr-missing".to_owned())]); + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "label": "managarr-verify" })) + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::AddTag("managarr-verify".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = seeded_map.clone(); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddTag("managarr-verify".to_owned())) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!(app.lock().await.data.readarr_data.tags_map, seeded_map); + } } From e8e3376327858f92a04be2340bcd37f6ffc9de24 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 10:21:55 -0600 Subject: [PATCH 022/170] feat(readarr): delete Readarr tags and expose the command on the CLI --- src/cli/readarr/delete_command_handler.rs | 67 +++++++++++ .../readarr/delete_command_handler_tests.rs | 111 ++++++++++++++++++ src/cli/readarr/mod.rs | 12 ++ src/network/readarr_network/mod.rs | 26 +++- .../readarr_network/readarr_network_tests.rs | 36 ++++++ 5 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/cli/readarr/delete_command_handler.rs create mode 100644 src/cli/readarr/delete_command_handler_tests.rs diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs new file mode 100644 index 00000000..cb483c90 --- /dev/null +++ b/src/cli/readarr/delete_command_handler.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "delete_command_handler_tests.rs"] +mod delete_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrDeleteCommand { + #[command(about = "Delete the tag with the specified ID")] + Tag { + #[arg(long, help = "The ID of the tag to delete", required = true)] + tag_id: i64, + }, +} + +impl From for Command { + fn from(value: ReadarrDeleteCommand) -> Self { + Command::Readarr(ReadarrCommand::Delete(value)) + } +} + +pub(super) struct ReadarrDeleteCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrDeleteCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> + for ReadarrDeleteCommandHandler<'a, 'b> +{ + fn with( + app: &'a Arc>>, + command: ReadarrDeleteCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrDeleteCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrDeleteCommand::Tag { tag_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteTag(tag_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs new file mode 100644 index 00000000..a1ccd3d1 --- /dev/null +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -0,0 +1,111 @@ +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser, error::ErrorKind}; + + use crate::{ + Cli, + cli::{ + Command, + readarr::{ReadarrCommand, delete_command_handler::ReadarrDeleteCommand}, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_delete_command_from() { + let command = ReadarrDeleteCommand::Tag { tag_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Delete(command))); + } + + mod cli { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_delete_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "tag"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_tag_tag_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "tag", + "--tag-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_tag_success() { + let expected_args = ReadarrDeleteCommand::Tag { tag_id: 1 }; + + let result = Cli::try_parse_from(["managarr", "readarr", "delete", "tag", "--tag-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::readarr::delete_command_handler::{ + ReadarrDeleteCommand, ReadarrDeleteCommandHandler, + }; + use crate::models::Serdeable; + use crate::models::readarr_models::ReadarrSerdeable; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_delete_tag_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteTag(1).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_tag_command = ReadarrDeleteCommand::Tag { tag_id: 1 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index e6f421a1..3d62bfc8 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; use anyhow::Result; use clap::Subcommand; +use delete_command_handler::{ReadarrDeleteCommand, ReadarrDeleteCommandHandler}; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use tokio::sync::Mutex; @@ -13,6 +14,7 @@ use crate::network::readarr_network::ReadarrEvent; use crate::{app::App, network::NetworkTrait}; mod add_command_handler; +mod delete_command_handler; mod get_command_handler; mod list_command_handler; @@ -27,6 +29,11 @@ pub enum ReadarrCommand { about = "Commands to add or create new resources within your Readarr instance" )] Add(ReadarrAddCommand), + #[command( + subcommand, + about = "Commands to delete resources from your Readarr instance" + )] + Delete(ReadarrDeleteCommand), #[command( subcommand, about = "Commands to fetch details of the resources in your Readarr instance" @@ -81,6 +88,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::Delete(delete_command) => { + ReadarrDeleteCommandHandler::with(self.app, delete_command, self.network) + .handle() + .await? + } ReadarrCommand::Get(get_command) => { ReadarrGetCommandHandler::with(self.app, get_command, self.network) .handle() diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 7846c42b..af59fe92 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -17,6 +17,7 @@ mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { AddTag(String), + DeleteTag(i64), GetDiskSpace, GetHostConfig, GetLogs(u64), @@ -46,7 +47,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetRootFolders => "/rootfolder", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", - ReadarrEvent::AddTag(_) | ReadarrEvent::GetTags => "/tag", + ReadarrEvent::AddTag(_) | ReadarrEvent::DeleteTag(_) | ReadarrEvent::GetTags => "/tag", ReadarrEvent::GetUpdates => "/update", } } @@ -65,6 +66,10 @@ impl Network<'_, '_> { ) -> Result { match readarr_event { ReadarrEvent::AddTag(tag) => self.add_readarr_tag(tag).await.map(ReadarrSerdeable::from), + ReadarrEvent::DeleteTag(tag_id) => self + .delete_readarr_tag(tag_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetDiskSpace => self .get_readarr_diskspace() .await @@ -201,4 +206,23 @@ impl Network<'_, '_> { }) .await } + + async fn delete_readarr_tag(&mut self, id: i64) -> Result<()> { + info!("Deleting Readarr tag with ID: {id}"); + let event = ReadarrEvent::DeleteTag(id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d224694d..c20b8d92 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -23,6 +23,7 @@ mod tests { #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] #[case(ReadarrEvent::AddTag(String::new()), "/tag")] + #[case(ReadarrEvent::DeleteTag(1), "/tag")] #[case(ReadarrEvent::GetTags, "/tag")] #[case(ReadarrEvent::GetUpdates, "/update")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { @@ -410,4 +411,39 @@ mod tests { assert_err!(result); assert_eq!(app.lock().await.data.readarr_data.tags_map, seeded_map); } + + #[tokio::test] + async fn test_handle_delete_readarr_tag_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .build_for(ReadarrEvent::DeleteTag(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteTag(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_tag_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .status(500) + .build_for(ReadarrEvent::DeleteTag(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteTag(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } } From 3475660eb24f8e0b8c24e584f672e8adabe42111 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 10:31:40 -0600 Subject: [PATCH 023/170] feat(readarr): add Readarr root folders and expose the command on the CLI --- src/cli/readarr/add_command_handler.rs | 68 +++++++++- src/cli/readarr/add_command_handler_tests.rs | 96 ++++++++++++++- src/models/readarr_models.rs | 14 +++ src/network/readarr_network/mod.rs | 45 ++++++- .../readarr_network/readarr_network_tests.rs | 60 ++++++++- .../readarr_network/root_folders/mod.rs | 34 ++++- .../readarr_root_folders_network_tests.rs | 116 +++++++++++++++++- 7 files changed, 426 insertions(+), 7 deletions(-) diff --git a/src/cli/readarr/add_command_handler.rs b/src/cli/readarr/add_command_handler.rs index 025cf6d6..e1306c0a 100644 --- a/src/cli/readarr/add_command_handler.rs +++ b/src/cli/readarr/add_command_handler.rs @@ -1,13 +1,14 @@ use std::sync::Arc; use anyhow::Result; -use clap::Subcommand; +use clap::{ArgAction, Subcommand}; use tokio::sync::Mutex; use super::ReadarrCommand; use crate::{ app::App, cli::{CliCommandHandler, Command}, + models::readarr_models::{AddReadarrRootFolderBody, MonitorType, NewItemMonitorType}, network::{NetworkTrait, readarr_network::ReadarrEvent}, }; @@ -17,6 +18,46 @@ mod add_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrAddCommand { + #[command(about = "Add a new root folder")] + RootFolder { + #[arg(long, help = "The name of the root folder", required = true)] + name: String, + #[arg(long, help = "The path of the new root folder", required = true)] + root_folder_path: String, + #[arg( + long, + help = "The ID of the default quality profile for authors in this root folder", + required = true + )] + quality_profile_id: i64, + #[arg( + long, + help = "The ID of the default metadata profile for authors in this root folder", + required = true + )] + metadata_profile_id: i64, + #[arg( + long, + help = "The default monitor option for authors in this root folder", + value_enum, + default_value_t = MonitorType::default() + )] + monitor: MonitorType, + #[arg( + long, + help = "The default monitor new items option for authors in this root folder", + value_enum, + default_value_t = NewItemMonitorType::default() + )] + monitor_new_items: NewItemMonitorType, + #[arg( + long, + help = "Default tag IDs for authors in this root folder", + value_parser, + action = ArgAction::Append + )] + tag: Vec, + }, #[command(about = "Add new tag")] Tag { #[arg(long, help = "The name of the tag to be added", required = true)] @@ -51,6 +92,31 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandH async fn handle(self) -> Result { let result = match self.command { + ReadarrAddCommand::RootFolder { + name, + root_folder_path, + quality_profile_id, + metadata_profile_id, + monitor, + monitor_new_items, + tag: tags, + } => { + let add_root_folder_body = AddReadarrRootFolderBody { + name, + path: root_folder_path, + default_quality_profile_id: quality_profile_id, + default_metadata_profile_id: metadata_profile_id, + default_monitor_option: monitor, + default_new_item_monitor_option: monitor_new_items, + default_tags: tags, + tag_input_string: None, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::AddRootFolder(add_root_folder_body).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrAddCommand::Tag { name } => { let resp = self .network diff --git a/src/cli/readarr/add_command_handler_tests.rs b/src/cli/readarr/add_command_handler_tests.rs index 21ac0c34..ba3ad687 100644 --- a/src/cli/readarr/add_command_handler_tests.rs +++ b/src/cli/readarr/add_command_handler_tests.rs @@ -8,6 +8,7 @@ mod tests { Command, readarr::{ReadarrCommand, add_command_handler::ReadarrAddCommand}, }, + models::readarr_models::{MonitorType, NewItemMonitorType}, }; use pretty_assertions::assert_eq; @@ -26,6 +27,53 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_add_root_folder_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "add", "root-folder"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_root_folder_success() { + let expected_args = ReadarrAddCommand::RootFolder { + name: "Books".to_owned(), + root_folder_path: "/nfs/test".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + tag: vec![], + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "root-folder", + "--name", + "Books", + "--root-folder-path", + "/nfs/test", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type"); + }; + assert_eq!(add_command, expected_args); + } + #[test] fn test_add_tag_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "add", "tag"]); @@ -64,13 +112,59 @@ mod tests { use crate::cli::CliCommandHandler; use crate::cli::readarr::add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; use crate::models::Serdeable; - use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::readarr_models::{ + AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, ReadarrSerdeable, + }; use crate::network::readarr_network::ReadarrEvent; use crate::{ app::App, network::{MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_add_root_folder_command() { + let expected_root_folder_path = "/nfs/test".to_owned(); + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: expected_root_folder_path.clone(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![1, 2], + tag_input_string: None, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::AddRootFolder(expected_add_root_folder_body.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_root_folder_command = ReadarrAddCommand::RootFolder { + name: "Books".to_owned(), + root_folder_path: expected_root_folder_path, + quality_profile_id: 1, + metadata_profile_id: 1, + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + tag: vec![1, 2], + }; + + let result = + ReadarrAddCommandHandler::with(&app_arc, add_root_folder_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_add_tag_command() { let expected_tag_name = "test".to_owned(); diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index 07a99fec..3e662e80 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -220,6 +220,20 @@ pub struct AddAuthorSearchResult { pub ratings: Option, } +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddReadarrRootFolderBody { + pub name: String, + pub path: String, + pub default_quality_profile_id: i64, + pub default_metadata_profile_id: i64, + pub default_monitor_option: MonitorType, + pub default_new_item_monitor_option: NewItemMonitorType, + pub default_tags: Vec, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, +} + #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Book { diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index af59fe92..bce1edc4 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -3,7 +3,7 @@ use log::info; use serde_json::{Value, json}; use super::{NetworkEvent, NetworkResource}; -use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; +use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable, ReadarrTaskName}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; @@ -16,6 +16,7 @@ mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { + AddRootFolder(AddReadarrRootFolderBody), AddTag(String), DeleteTag(i64), GetDiskSpace, @@ -44,7 +45,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", - ReadarrEvent::GetRootFolders => "/rootfolder", + ReadarrEvent::AddRootFolder(_) | ReadarrEvent::GetRootFolders => "/rootfolder", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", ReadarrEvent::AddTag(_) | ReadarrEvent::DeleteTag(_) | ReadarrEvent::GetTags => "/tag", @@ -65,6 +66,10 @@ impl Network<'_, '_> { readarr_event: ReadarrEvent, ) -> Result { match readarr_event { + ReadarrEvent::AddRootFolder(add_root_folder_body) => self + .add_readarr_root_folder(add_root_folder_body) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::AddTag(tag) => self.add_readarr_tag(tag).await.map(ReadarrSerdeable::from), ReadarrEvent::DeleteTag(tag_id) => self .delete_readarr_tag(tag_id) @@ -225,4 +230,40 @@ impl Network<'_, '_> { .handle_request::<(), ()>(request_props, |_, _| ()) .await } + + pub(in crate::network::readarr_network) async fn extract_and_add_readarr_tag_ids_vec( + &mut self, + edit_tags: &str, + ) -> Vec { + let missing_tags_vec = { + let tags_map = &self.app.lock().await.data.readarr_data.tags_map; + edit_tags + .split(',') + .filter(|&tag| { + !tag.is_empty() && tags_map.get_by_right(tag.to_lowercase().trim()).is_none() + }) + .collect::>() + }; + + for tag in missing_tags_vec { + self + .add_readarr_tag(tag.trim().to_owned()) + .await + .expect("Unable to add tag"); + } + + let app = self.app.lock().await; + edit_tags + .split(',') + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .readarr_data + .tags_map + .get_by_right(tag.to_lowercase().trim()) + .unwrap() + }) + .collect() + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index c20b8d92..8fad713b 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::ReadarrSerdeable; + use crate::app::App; + use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; @@ -8,6 +9,8 @@ mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::json; + use std::sync::Arc; + use tokio::sync::Mutex; #[rstest] #[case(ReadarrEvent::GetQueuedEvents, "/command")] @@ -19,6 +22,10 @@ mod tests { #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case( + ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()), + "/rootfolder" + )] #[case(ReadarrEvent::GetRootFolders, "/rootfolder")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] @@ -446,4 +453,55 @@ mod tests { mock.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_extract_and_add_readarr_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::test_default())); + let tags = " test,HI ,, usenet "; + { + let mut app = app_arc.lock().await; + app.data.readarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + app_arc.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app_arc); + + assert_eq!( + network.extract_and_add_readarr_tag_ids_vec(tags).await, + vec![2, 3, 1] + ); + } + + #[tokio::test] + async fn test_extract_and_add_readarr_tag_ids_vec_add_missing_tags_first() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "label": "TESTING" })) + .returns(json!({ "id": 3, "label": "testing" })) + .build_for(ReadarrEvent::GetTags) + .await; + let tags = "usenet, test, TESTING"; + { + let mut app_guard = app.lock().await; + app_guard.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let tag_ids_vec = network.extract_and_add_readarr_tag_ids_vec(tags).await; + + mock.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app.lock().await.data.readarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } } diff --git a/src/network/readarr_network/root_folders/mod.rs b/src/network/readarr_network/root_folders/mod.rs index 21c1030c..d683dc09 100644 --- a/src/network/readarr_network/root_folders/mod.rs +++ b/src/network/readarr_network/root_folders/mod.rs @@ -1,14 +1,46 @@ +use crate::models::readarr_models::AddReadarrRootFolderBody; use crate::models::servarr_models::RootFolder; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; -use log::info; +use log::{debug, info}; +use serde_json::Value; #[cfg(test)] #[path = "readarr_root_folders_network_tests.rs"] mod readarr_root_folders_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn add_readarr_root_folder( + &mut self, + mut add_root_folder_body: AddReadarrRootFolderBody, + ) -> Result { + info!("Adding new root folder to Readarr"); + if let Some(tag_input_str) = add_root_folder_body.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + add_root_folder_body.default_tags = tag_ids_vec; + } + let event = ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()); + + debug!("Add root folder body: {add_root_folder_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(add_root_folder_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_root_folders( &mut self, ) -> Result> { diff --git a/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs index b8a696dc..70eb63a8 100644 --- a/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs +++ b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs @@ -1,9 +1,12 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::readarr_models::{ + AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, ReadarrSerdeable, + }; use crate::models::servarr_models::RootFolder; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; + use bimap::BiMap; use pretty_assertions::assert_eq; use serde_json::json; @@ -17,6 +20,117 @@ mod tests { } } + #[tokio::test] + async fn test_handle_add_readarr_root_folder_event() { + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: "/nfs/test".to_owned(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![], + tag_input_string: Some("usenet, testing".to_owned()), + }; + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "Books", + "path": "/nfs/test", + "defaultQualityProfileId": 1, + "defaultMetadataProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [1, 2] + })) + .returns(json!({})) + .build_for(ReadarrEvent::AddRootFolder( + expected_add_root_folder_body.clone(), + )) + .await; + app.lock().await.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + assert_ok!( + network + .handle_readarr_event(ReadarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await + ); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_add_readarr_root_folder_event_does_not_overwrite_default_tags_vec_when_tag_input_string_is_none() + { + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: "/nfs/test".to_owned(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![1, 2], + tag_input_string: None, + }; + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "Books", + "path": "/nfs/test", + "defaultQualityProfileId": 1, + "defaultMetadataProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [1, 2] + })) + .returns(json!({})) + .build_for(ReadarrEvent::AddRootFolder( + expected_add_root_folder_body.clone(), + )) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + assert_ok!( + network + .handle_readarr_event(ReadarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await + ); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_add_readarr_root_folder_event_failure() { + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: "/nfs/test".to_owned(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![], + tag_input_string: None, + }; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({})) + .status(400) + .build_for(ReadarrEvent::AddRootFolder( + expected_add_root_folder_body.clone(), + )) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_readarr_root_folders_event() { let root_folders_json = json!([ From e1be1df68ac2e171d39b12767b536e35cb677140 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 10:38:58 -0600 Subject: [PATCH 024/170] feat(readarr): delete Readarr root folders and expose the command on the CLI --- src/cli/readarr/delete_command_handler.rs | 12 ++++ .../readarr/delete_command_handler_tests.rs | 72 +++++++++++++++++++ src/network/readarr_network/mod.rs | 9 ++- .../readarr_network/readarr_network_tests.rs | 1 + .../readarr_network/root_folders/mod.rs | 22 ++++++ .../readarr_root_folders_network_tests.rs | 35 +++++++++ 6 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs index cb483c90..a4da3e5c 100644 --- a/src/cli/readarr/delete_command_handler.rs +++ b/src/cli/readarr/delete_command_handler.rs @@ -17,6 +17,11 @@ mod delete_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrDeleteCommand { + #[command(about = "Delete the root folder with the given ID")] + RootFolder { + #[arg(long, help = "The ID of the root folder to delete", required = true)] + root_folder_id: i64, + }, #[command(about = "Delete the tag with the specified ID")] Tag { #[arg(long, help = "The ID of the tag to delete", required = true)] @@ -53,6 +58,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> async fn handle(self) -> Result { let result = match self.command { + ReadarrDeleteCommand::RootFolder { root_folder_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteRootFolder(root_folder_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrDeleteCommand::Tag { tag_id } => { let resp = self .network diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs index a1ccd3d1..3487186a 100644 --- a/src/cli/readarr/delete_command_handler_tests.rs +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -24,6 +24,55 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_delete_root_folder_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "root-folder"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_root_folder_root_folder_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "root-folder", + "--root-folder-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_root_folder_success() { + let expected_args = ReadarrDeleteCommand::RootFolder { root_folder_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "root-folder", + "--root-folder-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + #[test] fn test_delete_tag_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "tag"]); @@ -85,6 +134,29 @@ mod tests { network::{MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_delete_root_folder_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteRootFolder(1).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_root_folder_command = ReadarrDeleteCommand::RootFolder { root_folder_id: 1 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_root_folder_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_delete_tag_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index bce1edc4..3fc0878c 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -18,6 +18,7 @@ mod readarr_network_tests; pub enum ReadarrEvent { AddRootFolder(AddReadarrRootFolderBody), AddTag(String), + DeleteRootFolder(i64), DeleteTag(i64), GetDiskSpace, GetHostConfig, @@ -45,7 +46,9 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", - ReadarrEvent::AddRootFolder(_) | ReadarrEvent::GetRootFolders => "/rootfolder", + ReadarrEvent::AddRootFolder(_) + | ReadarrEvent::DeleteRootFolder(_) + | ReadarrEvent::GetRootFolders => "/rootfolder", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", ReadarrEvent::AddTag(_) | ReadarrEvent::DeleteTag(_) | ReadarrEvent::GetTags => "/tag", @@ -71,6 +74,10 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::AddTag(tag) => self.add_readarr_tag(tag).await.map(ReadarrSerdeable::from), + ReadarrEvent::DeleteRootFolder(root_folder_id) => self + .delete_readarr_root_folder(root_folder_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::DeleteTag(tag_id) => self .delete_readarr_tag(tag_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 8fad713b..884fb639 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -26,6 +26,7 @@ mod tests { ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()), "/rootfolder" )] + #[case(ReadarrEvent::DeleteRootFolder(1), "/rootfolder")] #[case(ReadarrEvent::GetRootFolders, "/rootfolder")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] diff --git a/src/network/readarr_network/root_folders/mod.rs b/src/network/readarr_network/root_folders/mod.rs index d683dc09..dca86c96 100644 --- a/src/network/readarr_network/root_folders/mod.rs +++ b/src/network/readarr_network/root_folders/mod.rs @@ -41,6 +41,28 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn delete_readarr_root_folder( + &mut self, + root_folder_id: i64, + ) -> Result<()> { + info!("Deleting Readarr root folder with ID: {root_folder_id}"); + let event = ReadarrEvent::DeleteRootFolder(root_folder_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{root_folder_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_root_folders( &mut self, ) -> Result> { diff --git a/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs index 70eb63a8..3a1c8579 100644 --- a/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs +++ b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs @@ -131,6 +131,41 @@ mod tests { assert_err!(result); } + #[tokio::test] + async fn test_handle_delete_readarr_root_folder_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .build_for(ReadarrEvent::DeleteRootFolder(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteRootFolder(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_root_folder_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .status(500) + .build_for(ReadarrEvent::DeleteRootFolder(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteRootFolder(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_readarr_root_folders_event() { let root_folders_json = json!([ From 84eba9beacb7ffe775a38a62285ff627c597a742 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 11:41:42 -0600 Subject: [PATCH 025/170] feat(readarr): fetch Readarr authors and expose them on the CLI Add ReadarrEvent::ListAuthors, mapping to GET /author, and wire it up as `managarr readarr list authors`. This is the first Readarr event that touches the library rather than configuration, so it creates the library/authors module tree mirroring Lidarr's library/artists layout. list_authors mirrors Lidarr's list_artists, including the sort-prompt route guard: a response landing while the user has the sort prompt open must not clobber their in-progress sort selection. Both directions of that guard are covered by tests. --- src/cli/readarr/list_command_handler.rs | 9 + src/cli/readarr/list_command_handler_tests.rs | 24 +++ .../readarr_network/library/authors/mod.rs | 36 ++++ .../authors/readarr_authors_network_tests.rs | 157 ++++++++++++++++++ src/network/readarr_network/library/mod.rs | 1 + src/network/readarr_network/mod.rs | 4 + .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 232 insertions(+) create mode 100644 src/network/readarr_network/library/authors/mod.rs create mode 100644 src/network/readarr_network/library/authors/readarr_authors_network_tests.rs create mode 100644 src/network/readarr_network/library/mod.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 1723ba16..2ddf9180 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -17,6 +17,8 @@ mod list_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrListCommand { + #[command(about = "List all authors in your Readarr library")] + Authors, #[command(about = "List disk space details for all provisioned root folders in Readarr")] DiskSpace, #[command(about = "Fetch Readarr logs")] @@ -72,6 +74,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman async fn handle(self) -> Result { let result = match self.command { + ReadarrListCommand::Authors => { + let resp = self + .network + .handle_network_event(ReadarrEvent::ListAuthors.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::DiskSpace => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 14c3e2ee..247d7d34 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -26,6 +26,7 @@ mod tests { #[rstest] fn test_list_commands_have_no_arg_requirements( #[values( + "authors", "disk-space", "metadata-profiles", "quality-profiles", @@ -87,6 +88,29 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; + #[tokio::test] + async fn test_handle_list_authors_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::ListAuthors.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_authors_command = ReadarrListCommand::Authors; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_authors_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_disk_space_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs new file mode 100644 index 00000000..65d1a140 --- /dev/null +++ b/src/network/readarr_network/library/authors/mod.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use log::info; + +use crate::models::Route; +use crate::models::readarr_models::Author; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_authors_network_tests.rs"] +mod readarr_authors_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn list_authors(&mut self) -> Result> { + info!("Fetching Readarr authors"); + let event = ReadarrEvent::ListAuthors; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut authors_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::AuthorsSortPrompt, _) + ) { + authors_vec.sort_by_key(|a| a.id); + app.data.readarr_data.authors.set_items(authors_vec); + app.data.readarr_data.authors.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs new file mode 100644 index 00000000..83ddf5e4 --- /dev/null +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -0,0 +1,157 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{Author, ReadarrSerdeable}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_list_authors_event() { + let authors_json = json!([ + { + "id": 2, + "authorName": "Zeta Author", + "foreignAuthorId": "foreign-author-2", + "status": "continuing", + "overview": "An overview of Zeta Author", + "path": "/nfs/books/Zeta Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["Fantasy"], + "tags": [1] + }, + { + "id": 1, + "authorName": "Alpha Author", + "foreignAuthorId": "foreign-author-1", + "status": "continuing", + "overview": "An overview of Alpha Author", + "path": "/nfs/books/Alpha Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["Fantasy"], + "tags": [1] + } + ]); + let response: Vec = serde_json::from_value(authors_json.clone()).unwrap(); + let mut sorted_authors = response.clone(); + sorted_authors.sort_by_key(|author| author.id); + let (mock, app, _server) = MockServarrApi::get() + .returns(authors_json) + .build_for(ReadarrEvent::ListAuthors) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ListAuthors) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Authors(authors) = result.unwrap() else { + panic!("Expected Authors") + }; + + assert_eq!(authors, response); + assert_eq!( + app.lock().await.data.readarr_data.authors.items, + sorted_authors + ); + } + + #[tokio::test] + async fn test_handle_list_authors_event_no_op_when_user_is_selecting_sort_options() { + let authors_json = json!([{ + "id": 1, + "authorName": "Alpha Author", + "foreignAuthorId": "foreign-author-1", + "status": "continuing", + "path": "/nfs/books/Alpha Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": [], + "tags": [] + }]); + let (mock, app, _server) = MockServarrApi::get() + .returns(authors_json) + .build_for(ReadarrEvent::ListAuthors) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.push_navigation_stack(ActiveReadarrBlock::AuthorsSortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ListAuthors) + .await; + + mock.assert_async().await; + + assert_ok!(result); + assert_is_empty!(app.lock().await.data.readarr_data.authors); + } + + #[tokio::test] + async fn test_handle_list_authors_event_failure() { + let stale_authors: Vec = serde_json::from_value(json!([{ + "id": 99, + "authorName": "Stale Author", + "foreignAuthorId": "foreign-author-99", + "status": "continuing", + "path": "/nfs/books/Stale Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": [], + "tags": [] + }])) + .unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::ListAuthors) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .authors + .set_items(stale_authors.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ListAuthors) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.authors.items, + stale_authors + ); + } +} diff --git a/src/network/readarr_network/library/mod.rs b/src/network/readarr_network/library/mod.rs new file mode 100644 index 00000000..4f299636 --- /dev/null +++ b/src/network/readarr_network/library/mod.rs @@ -0,0 +1 @@ +mod authors; diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 3fc0878c..2bc0dafa 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -7,6 +7,7 @@ use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable, use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; +mod library; mod root_folders; mod system; @@ -33,12 +34,14 @@ pub enum ReadarrEvent { GetTasks, GetUpdates, HealthCheck, + ListAuthors, StartTask(ReadarrTaskName), } impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { + ReadarrEvent::ListAuthors => "/author", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", @@ -122,6 +125,7 @@ impl Network<'_, '_> { .get_readarr_healthcheck() .await .map(ReadarrSerdeable::from), + ReadarrEvent::ListAuthors => self.list_authors().await.map(ReadarrSerdeable::from), ReadarrEvent::StartTask(task_name) => self .start_readarr_task(task_name) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 884fb639..8bf06a17 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -13,6 +13,7 @@ mod tests { use tokio::sync::Mutex; #[rstest] + #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] From 817fe4d135605b651716af4bbea5521048eae06e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 14:23:15 -0600 Subject: [PATCH 026/170] test(readarr): add shared network test factories --- .../authors/readarr_authors_network_tests.rs | 36 ++--------- src/network/readarr_network/mod.rs | 4 ++ .../readarr_network_test_utils.rs | 61 +++++++++++++++++++ .../readarr_root_folders_network_tests.rs | 11 +--- 4 files changed, 72 insertions(+), 40 deletions(-) create mode 100644 src/network/readarr_network/readarr_network_test_utils.rs diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index 83ddf5e4..3f85f90c 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -4,8 +4,11 @@ mod tests { use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + AUTHOR_JSON, stale_author, + }; use pretty_assertions::assert_eq; - use serde_json::json; + use serde_json::{Value, json}; #[tokio::test] async fn test_handle_list_authors_event() { @@ -74,20 +77,7 @@ mod tests { #[tokio::test] async fn test_handle_list_authors_event_no_op_when_user_is_selecting_sort_options() { - let authors_json = json!([{ - "id": 1, - "authorName": "Alpha Author", - "foreignAuthorId": "foreign-author-1", - "status": "continuing", - "path": "/nfs/books/Alpha Author", - "rootFolderPath": "/nfs/books/", - "qualityProfileId": 1, - "metadataProfileId": 1, - "monitored": true, - "monitorNewItems": "all", - "genres": [], - "tags": [] - }]); + let authors_json = json!([serde_json::from_str::(AUTHOR_JSON).unwrap()]); let (mock, app, _server) = MockServarrApi::get() .returns(authors_json) .build_for(ReadarrEvent::ListAuthors) @@ -111,21 +101,7 @@ mod tests { #[tokio::test] async fn test_handle_list_authors_event_failure() { - let stale_authors: Vec = serde_json::from_value(json!([{ - "id": 99, - "authorName": "Stale Author", - "foreignAuthorId": "foreign-author-99", - "status": "continuing", - "path": "/nfs/books/Stale Author", - "rootFolderPath": "/nfs/books/", - "qualityProfileId": 1, - "metadataProfileId": 1, - "monitored": true, - "monitorNewItems": "all", - "genres": [], - "tags": [] - }])) - .unwrap(); + let stale_authors = vec![stale_author()]; let (mock, app, _server) = MockServarrApi::get() .returns(json!({})) .status(500) diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 2bc0dafa..1bedecf1 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -15,6 +15,10 @@ mod system; #[path = "readarr_network_tests.rs"] mod readarr_network_tests; +#[cfg(test)] +#[path = "readarr_network_test_utils.rs"] +pub mod readarr_network_test_utils; + #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { AddRootFolder(AddReadarrRootFolderBody), diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs new file mode 100644 index 00000000..4ecf5bd4 --- /dev/null +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -0,0 +1,61 @@ +#[cfg(test)] +#[allow(dead_code)] +pub mod test_utils { + use crate::models::readarr_models::{Author, AuthorStatus, NewItemMonitorType}; + use crate::models::servarr_models::RootFolder; + + pub const AUTHOR_JSON: &str = r#"{ + "id": 1, + "authorName": "Test Author", + "foreignAuthorId": "test-foreign-id", + "status": "continuing", + "overview": "some interesting description of the author", + "authorType": "Person", + "disambiguation": "American novelist", + "path": "/nfs/books/Test Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["science fiction"], + "tags": [1], + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "statistics": { + "bookCount": 3, + "bookFileCount": 2, + "totalBookCount": 3, + "availableBookCount": 2, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + } + }"#; + + pub fn stale_author() -> Author { + Author { + id: 99, + author_name: "Stale Author".into(), + foreign_author_id: "foreign-author-99".to_owned(), + status: AuthorStatus::Continuing, + path: "/nfs/books/Stale Author".to_owned(), + root_folder_path: Some("/nfs/books/".to_owned()), + quality_profile_id: 1, + metadata_profile_id: 1, + monitored: true, + monitor_new_items: NewItemMonitorType::All, + genres: vec![], + tags: vec![], + ..Author::default() + } + } + + pub fn stale_root_folder() -> RootFolder { + RootFolder { + id: 99, + path: "/stale".to_owned(), + accessible: false, + free_space: 1, + unmapped_folders: None, + } + } +} diff --git a/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs index 3a1c8579..29b3fa93 100644 --- a/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs +++ b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs @@ -6,20 +6,11 @@ mod tests { use crate::models::servarr_models::RootFolder; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::stale_root_folder; use bimap::BiMap; use pretty_assertions::assert_eq; use serde_json::json; - fn stale_root_folder() -> RootFolder { - RootFolder { - id: 99, - path: "/stale".to_owned(), - accessible: false, - free_space: 1, - unmapped_folders: None, - } - } - #[tokio::test] async fn test_handle_add_readarr_root_folder_event() { let expected_add_root_folder_body = AddReadarrRootFolderBody { From 5042dbdb3ad9dada18f67f79bd27253ee371a7d8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 14:33:55 -0600 Subject: [PATCH 027/170] feat(readarr): fetch Readarr author details and expose them on the CLI --- src/cli/readarr/get_command_handler.rs | 16 ++++++ src/cli/readarr/get_command_handler_tests.rs | 57 ++++++++++++++++++- .../readarr_network/library/authors/mod.rs | 22 +++++++ .../authors/readarr_authors_network_tests.rs | 57 +++++++++++++++++++ src/network/readarr_network/mod.rs | 7 ++- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 158 insertions(+), 2 deletions(-) diff --git a/src/cli/readarr/get_command_handler.rs b/src/cli/readarr/get_command_handler.rs index a0b8b6bb..91bb322e 100644 --- a/src/cli/readarr/get_command_handler.rs +++ b/src/cli/readarr/get_command_handler.rs @@ -18,6 +18,15 @@ mod get_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrGetCommand { + #[command(about = "Get detailed information for the author with the given ID")] + AuthorDetails { + #[arg( + long, + help = "The Readarr ID of the author whose details you wish to fetch", + required = true + )] + author_id: i64, + }, #[command(about = "Fetch the host config for your Readarr instance")] HostConfig, #[command(about = "Fetch the security config for your Readarr instance")] @@ -53,6 +62,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrGetCommand> for ReadarrGetCommandH async fn handle(self) -> Result { let result = match self.command { + ReadarrGetCommand::AuthorDetails { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetAuthorDetails(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrGetCommand::HostConfig => { let resp = self .network diff --git a/src/cli/readarr/get_command_handler_tests.rs b/src/cli/readarr/get_command_handler_tests.rs index 5bd8ccc9..d12d7ca4 100644 --- a/src/cli/readarr/get_command_handler_tests.rs +++ b/src/cli/readarr/get_command_handler_tests.rs @@ -18,9 +18,38 @@ mod tests { } mod cli { - use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; use rstest::rstest; + use super::*; + + #[test] + fn test_author_details_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "get", "author-details"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_author_details_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "get", + "author-details", + "--author-id", + "1", + ]); + + assert_ok!(&result); + } + #[rstest] fn test_get_commands_have_no_arg_requirements( #[values("host-config", "security-config", "system-status")] subcommand: &str, @@ -48,6 +77,32 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; + #[tokio::test] + async fn test_handle_get_author_details_command() { + let expected_author_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAuthorDetails(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_author_details_command = ReadarrGetCommand::AuthorDetails { author_id: 1 }; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_author_details_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_get_host_config_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 65d1a140..fb4e85fb 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -12,6 +12,28 @@ use crate::network::{Network, RequestMethod}; mod readarr_authors_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_author_details( + &mut self, + author_id: i64, + ) -> Result { + info!("Fetching details for Readarr author with ID: {author_id}"); + let event = ReadarrEvent::GetAuthorDetails(author_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{author_id}")), + None, + ) + .await; + + self + .handle_request::<(), Author>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn list_authors(&mut self) -> Result> { info!("Fetching Readarr authors"); let event = ReadarrEvent::ListAuthors; diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index 3f85f90c..eb0ef2cd 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -10,6 +10,63 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::{Value, json}; + #[tokio::test] + async fn test_handle_get_author_details_event() { + let expected_author: Author = serde_json::from_str(AUTHOR_JSON).unwrap(); + let stale_authors = vec![stale_author()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .authors + .set_items(stale_authors.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorDetails(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Author(author) = result.unwrap() else { + panic!("Expected Author") + }; + + assert_eq!(author, expected_author); + assert_eq!( + app.lock().await.data.readarr_data.authors.items, + stale_authors + ); + } + + #[tokio::test] + async fn test_handle_get_author_details_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .status(500) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorDetails(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + } + #[tokio::test] async fn test_handle_list_authors_event() { let authors_json = json!([ diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 1bedecf1..f0657670 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -25,6 +25,7 @@ pub enum ReadarrEvent { AddTag(String), DeleteRootFolder(i64), DeleteTag(i64), + GetAuthorDetails(i64), GetDiskSpace, GetHostConfig, GetLogs(u64), @@ -45,7 +46,7 @@ pub enum ReadarrEvent { impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { - ReadarrEvent::ListAuthors => "/author", + ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", @@ -89,6 +90,10 @@ impl Network<'_, '_> { .delete_readarr_tag(tag_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetAuthorDetails(author_id) => self + .get_author_details(author_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetDiskSpace => self .get_readarr_diskspace() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 8bf06a17..d35eb305 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -13,6 +13,7 @@ mod tests { use tokio::sync::Mutex; #[rstest] + #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] From 9238c1b9d2066dc668d276c39e29872494afcf2d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 16:57:26 -0600 Subject: [PATCH 028/170] feat(readarr): search for new Readarr authors and expose the command on the CLI --- src/cli/readarr/mod.rs | 16 +++ src/cli/readarr/readarr_command_tests.rs | 52 +++++++++ .../readarr_network/library/authors/mod.rs | 44 +++++++- .../authors/readarr_authors_network_tests.rs | 105 +++++++++++++++++- src/network/readarr_network/mod.rs | 5 + .../readarr_network_test_utils.rs | 25 ++++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 244 insertions(+), 4 deletions(-) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 3d62bfc8..37e47506 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -44,6 +44,15 @@ pub enum ReadarrCommand { about = "Commands to list attributes from your Readarr instance" )] List(ReadarrListCommand), + #[command(about = "Search for a new author to add to Readarr")] + SearchNewAuthor { + #[arg( + long, + help = "The name of the author you want to search for", + required = true + )] + query: String, + }, #[command(about = "Start the specified Readarr task")] StartTask { #[arg( @@ -103,6 +112,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::SearchNewAuthor { query } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::SearchNewAuthor(query).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrCommand::StartTask { task_name } => { let resp = self .network diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index 00cb2582..c0b25d9e 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -14,6 +14,31 @@ mod tests { assert_err!(&result); } + #[test] + fn test_search_new_author_requires_query() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "search-new-author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_search_new_author_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "search-new-author", + "--query", + "test query", + ]); + + assert_ok!(&result); + } + #[test] fn test_start_task_requires_task_name() { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "start-task"]); @@ -71,6 +96,33 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; + #[tokio::test] + async fn test_search_new_author_command() { + let expected_query = "test author".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::SearchNewAuthor(expected_query.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let search_new_author_command = ReadarrCommand::SearchNewAuthor { + query: expected_query, + }; + + let result = ReadarrCliHandler::with(&app_arc, search_new_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_start_task_command() { let expected_task_name = ReadarrTaskName::CheckHealth; diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index fb4e85fb..32643d27 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -2,10 +2,12 @@ use anyhow::Result; use log::info; use crate::models::Route; -use crate::models::readarr_models::Author; +use crate::models::readarr_models::{AddAuthorSearchResult, Author}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::models::stateful_table::StatefulTable; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; +use urlencoding::encode; #[cfg(test)] #[path = "readarr_authors_network_tests.rs"] @@ -55,4 +57,44 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn search_author( + &mut self, + query: String, + ) -> Result> { + info!("Searching for author: {query}"); + let event = ReadarrEvent::SearchNewAuthor(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("term={}", encode(&query))), + ) + .await; + + let result = self + .handle_request::<(), Vec>(request_props, |author_vec, mut app| { + if author_vec.is_empty() { + app.pop_and_push_navigation_stack(ActiveReadarrBlock::AddAuthorEmptySearchResults.into()); + } else if let Some(add_searched_authors) = + app.data.readarr_data.add_searched_authors.as_mut() + { + add_searched_authors.set_items(author_vec); + } else { + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(author_vec); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + } + }) + .await; + + if result.is_err() { + self.app.lock().await.data.readarr_data.add_searched_authors = Some(StatefulTable::default()); + } + + result + } } diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index eb0ef2cd..e21f0dfe 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -1,11 +1,12 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::{Author, ReadarrSerdeable}; + use crate::models::readarr_models::{AddAuthorSearchResult, Author, ReadarrSerdeable}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::StatefulTable; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - AUTHOR_JSON, stale_author, + ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, stale_add_author_search_result, stale_author, }; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -187,4 +188,104 @@ mod tests { stale_authors ); } + + #[tokio::test] + async fn test_handle_search_new_author_event() { + let search_results_json = + json!([serde_json::from_str::(ADD_AUTHOR_SEARCH_RESULT_JSON).unwrap()]); + let expected_results: Vec = + serde_json::from_value(search_results_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(search_results_json) + .query("term=test%20%26%20author") + .build_for(ReadarrEvent::SearchNewAuthor("test & author".to_owned())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::SearchNewAuthor("test & author".to_owned())) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::AddAuthorSearchResults(search_results) = result.unwrap() else { + panic!("Expected AddAuthorSearchResults") + }; + + assert_eq!(search_results, expected_results); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .add_searched_authors + .as_ref() + .unwrap() + .items, + expected_results + ); + } + + #[tokio::test] + async fn test_handle_search_new_author_event_navigates_to_empty_results_when_empty() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .query("term=nonexistent") + .build_for(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_ok!(result); + let app = app.lock().await; + assert_none!(&app.data.readarr_data.add_searched_authors); + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorEmptySearchResults.into() + ); + } + + #[tokio::test] + async fn test_handle_search_new_author_event_sets_empty_table_on_api_error() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([serde_json::from_str::( + ADD_AUTHOR_SEARCH_RESULT_JSON + ) + .unwrap()])) + .status(500) + .query("term=nonexistent") + .build_for(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut stale_table = StatefulTable::default(); + stale_table.set_items(vec![stale_add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(stale_table); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_err!(result); + let app = app.lock().await; + assert_some!(&app.data.readarr_data.add_searched_authors); + assert_is_empty!(app.data.readarr_data.add_searched_authors.as_ref().unwrap()); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index f0657670..f665a5bd 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -40,6 +40,7 @@ pub enum ReadarrEvent { GetUpdates, HealthCheck, ListAuthors, + SearchNewAuthor(String), StartTask(ReadarrTaskName), } @@ -47,6 +48,7 @@ impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author", + ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", @@ -135,6 +137,9 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::ListAuthors => self.list_authors().await.map(ReadarrSerdeable::from), + ReadarrEvent::SearchNewAuthor(query) => { + self.search_author(query).await.map(ReadarrSerdeable::from) + } ReadarrEvent::StartTask(task_name) => self .start_readarr_task(task_name) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 4ecf5bd4..90578f88 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -1,7 +1,9 @@ #[cfg(test)] #[allow(dead_code)] pub mod test_utils { - use crate::models::readarr_models::{Author, AuthorStatus, NewItemMonitorType}; + use crate::models::readarr_models::{ + AddAuthorSearchResult, Author, AuthorStatus, NewItemMonitorType, + }; use crate::models::servarr_models::RootFolder; pub const AUTHOR_JSON: &str = r#"{ @@ -31,6 +33,18 @@ pub mod test_utils { } }"#; + pub const ADD_AUTHOR_SEARCH_RESULT_JSON: &str = r#"{ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "status": "continuing", + "ended": false, + "overview": "some interesting description of the author", + "authorType": "Person", + "disambiguation": "American novelist", + "genres": ["science fiction"], + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }"#; + pub fn stale_author() -> Author { Author { id: 99, @@ -49,6 +63,15 @@ pub mod test_utils { } } + pub fn stale_add_author_search_result() -> AddAuthorSearchResult { + AddAuthorSearchResult { + foreign_author_id: "foreign-author-99".to_owned(), + author_name: "Stale Author".into(), + status: AuthorStatus::Continuing, + ..AddAuthorSearchResult::default() + } + } + pub fn stale_root_folder() -> RootFolder { RootFolder { id: 99, diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d35eb305..a392a3df 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -15,6 +15,7 @@ mod tests { #[rstest] #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::ListAuthors, "/author")] + #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] From e514e9b6cca4b9dc5a0e5dafb85b113902745298 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 17:17:41 -0600 Subject: [PATCH 029/170] feat(readarr): add authors to Readarr and expose the command on the CLI --- src/cli/readarr/add_command_handler.rs | 94 ++++- src/cli/readarr/add_command_handler_tests.rs | 368 +++++++++++++++++- src/models/readarr_models.rs | 23 ++ .../readarr_network/library/authors/mod.rs | 31 +- .../authors/readarr_authors_network_tests.rs | 103 ++++- src/network/readarr_network/mod.rs | 10 +- .../readarr_network_test_utils.rs | 21 +- .../readarr_network/readarr_network_tests.rs | 3 +- 8 files changed, 644 insertions(+), 9 deletions(-) diff --git a/src/cli/readarr/add_command_handler.rs b/src/cli/readarr/add_command_handler.rs index e1306c0a..b66de995 100644 --- a/src/cli/readarr/add_command_handler.rs +++ b/src/cli/readarr/add_command_handler.rs @@ -8,7 +8,9 @@ use super::ReadarrCommand; use crate::{ app::App, cli::{CliCommandHandler, Command}, - models::readarr_models::{AddReadarrRootFolderBody, MonitorType, NewItemMonitorType}, + models::readarr_models::{ + AddAuthorBody, AddAuthorOptions, AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, + }, network::{NetworkTrait, readarr_network::ReadarrEvent}, }; @@ -18,6 +20,63 @@ mod add_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrAddCommand { + #[command(about = "Add a new author to your Readarr library")] + Author { + #[arg( + long, + help = "The Goodreads foreign author ID of the author you wish to add to your library", + required = true + )] + foreign_author_id: String, + #[arg(long, help = "The name of the author", required = true)] + author_name: String, + #[arg( + long, + help = "The root folder path where all author data and metadata should live", + required = true + )] + root_folder_path: String, + #[arg( + long, + help = "The ID of the quality profile to use for this author", + required = true + )] + quality_profile_id: i64, + #[arg( + long, + help = "The ID of the metadata profile to use for this author", + required = true + )] + metadata_profile_id: i64, + #[arg(long, help = "Disable monitoring for this author")] + disable_monitoring: bool, + #[arg( + long, + help = "Tag IDs to tag the author with", + value_parser, + action = ArgAction::Append + )] + tag: Vec, + #[arg( + long, + help = "What Readarr should monitor for this author", + value_enum, + default_value_t = MonitorType::default() + )] + monitor: MonitorType, + #[arg( + long, + help = "How Readarr should monitor new items for this author", + value_enum, + default_value_t = NewItemMonitorType::default() + )] + monitor_new_items: NewItemMonitorType, + #[arg( + long, + help = "Tell Readarr to not start a search for missing books once the author is added to your library" + )] + no_search_for_missing_books: bool, + }, #[command(about = "Add a new root folder")] RootFolder { #[arg(long, help = "The name of the root folder", required = true)] @@ -92,6 +151,39 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandH async fn handle(self) -> Result { let result = match self.command { + ReadarrAddCommand::Author { + foreign_author_id, + author_name, + root_folder_path, + quality_profile_id, + metadata_profile_id, + disable_monitoring, + tag: tags, + monitor, + monitor_new_items, + no_search_for_missing_books, + } => { + let body = AddAuthorBody { + foreign_author_id, + author_name, + monitored: !disable_monitoring, + root_folder_path, + quality_profile_id, + metadata_profile_id, + tags, + tag_input_string: None, + add_options: AddAuthorOptions { + monitor, + monitor_new_items, + search_for_missing_books: !no_search_for_missing_books, + }, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::AddAuthor(body).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrAddCommand::RootFolder { name, root_folder_path, diff --git a/src/cli/readarr/add_command_handler_tests.rs b/src/cli/readarr/add_command_handler_tests.rs index ba3ad687..33c4698c 100644 --- a/src/cli/readarr/add_command_handler_tests.rs +++ b/src/cli/readarr/add_command_handler_tests.rs @@ -27,6 +27,321 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_add_author_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "add", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_foreign_author_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--author-name", + "Test", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_author_name() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_root_folder_path() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_quality_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test", + "--root-folder-path", + "/nfs/books", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_metadata_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_success_with_required_args_only() { + let expected_args = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + disable_monitoring: false, + tag: vec![], + monitor: MonitorType::default(), + monitor_new_items: NewItemMonitorType::default(), + no_search_for_missing_books: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + + #[test] + fn test_add_author_success_with_all_args() { + let expected_args = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 2, + disable_monitoring: true, + tag: vec![1, 2], + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::New, + no_search_for_missing_books: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--disable-monitoring", + "--tag", + "1", + "--tag", + "2", + "--monitor", + "future", + "--monitor-new-items", + "new", + "--no-search-for-missing-books", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + + #[test] + fn test_add_author_monitor_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--monitor", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_author_new_item_monitor_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--monitor-new-items", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_author_tags_is_repeatable() { + let expected_args = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 2, + disable_monitoring: false, + tag: vec![1, 2], + monitor: MonitorType::default(), + monitor_new_items: NewItemMonitorType::default(), + no_search_for_missing_books: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + #[test] fn test_add_root_folder_requires_arguments() { let result = @@ -113,7 +428,8 @@ mod tests { use crate::cli::readarr::add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; use crate::models::Serdeable; use crate::models::readarr_models::{ - AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, ReadarrSerdeable, + AddAuthorBody, AddAuthorOptions, AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, + ReadarrSerdeable, }; use crate::network::readarr_network::ReadarrEvent; use crate::{ @@ -121,6 +437,56 @@ mod tests { network::{MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_add_author_command() { + let expected_body = AddAuthorBody { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + monitored: false, + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + tags: vec![1, 2], + tag_input_string: None, + add_options: AddAuthorOptions { + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + search_for_missing_books: false, + }, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::AddAuthor(expected_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_author_command = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + disable_monitoring: true, + tag: vec![1, 2], + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + no_search_for_missing_books: true, + }; + + let result = ReadarrAddCommandHandler::with(&app_arc, add_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_add_root_folder_command() { let expected_root_folder_path = "/nfs/test".to_owned(); diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index 3e662e80..e54051bb 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -220,6 +220,29 @@ pub struct AddAuthorSearchResult { pub ratings: Option, } +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddAuthorBody { + pub foreign_author_id: String, + pub author_name: String, + pub monitored: bool, + pub root_folder_path: String, + pub quality_profile_id: i64, + pub metadata_profile_id: i64, + pub tags: Vec, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, + pub add_options: AddAuthorOptions, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddAuthorOptions { + pub monitor: MonitorType, + pub monitor_new_items: NewItemMonitorType, + pub search_for_missing_books: bool, +} + #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddReadarrRootFolderBody { diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 32643d27..1938aa8d 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -1,8 +1,9 @@ use anyhow::Result; use log::info; +use serde_json::Value; use crate::models::Route; -use crate::models::readarr_models::{AddAuthorSearchResult, Author}; +use crate::models::readarr_models::{AddAuthorBody, AddAuthorSearchResult, Author}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; use crate::network::readarr_network::ReadarrEvent; @@ -14,6 +15,34 @@ use urlencoding::encode; mod readarr_authors_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn add_author( + &mut self, + mut add_author_body: AddAuthorBody, + ) -> Result { + info!("Adding Readarr author: {}", add_author_body.author_name); + if let Some(tag_input_str) = add_author_body.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + add_author_body.tags = tag_ids_vec; + } + let event = ReadarrEvent::AddAuthor(AddAuthorBody::default()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(add_author_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_author_details( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index e21f0dfe..4a2b5453 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -1,13 +1,17 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::{AddAuthorSearchResult, Author, ReadarrSerdeable}; + use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorSearchResult, Author, ReadarrSerdeable, + }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, stale_add_author_search_result, stale_author, + ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, add_author_body, stale_add_author_search_result, + stale_author, }; + use bimap::BiMap; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -288,4 +292,99 @@ mod tests { assert_some!(&app.data.readarr_data.add_searched_authors); assert_is_empty!(app.data.readarr_data.add_searched_authors.as_ref().unwrap()); } + + #[tokio::test] + async fn test_handle_add_author_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "monitored": true, + "rootFolderPath": "/nfs/books", + "qualityProfileId": 1, + "metadataProfileId": 1, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "monitorNewItems": "all", + "searchForMissingBooks": true + } + })) + .returns(json!({"id": 1})) + .build_for(ReadarrEvent::AddAuthor(AddAuthorBody::default())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddAuthor(add_author_body())) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_add_author_event_does_not_overwrite_tags_vec_when_tag_input_string_is_none() + { + let add_author_body = AddAuthorBody { + tags: vec![1, 2], + tag_input_string: None, + ..add_author_body() + }; + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "monitored": true, + "rootFolderPath": "/nfs/books", + "qualityProfileId": 1, + "metadataProfileId": 1, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "monitorNewItems": "all", + "searchForMissingBooks": true + } + })) + .returns(json!({"id": 1})) + .build_for(ReadarrEvent::AddAuthor(add_author_body.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddAuthor(add_author_body)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_add_author_event_failure() { + let add_author_body = AddAuthorBody { + tag_input_string: None, + ..add_author_body() + }; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({"id": 1})) + .status(400) + .build_for(ReadarrEvent::AddAuthor(add_author_body.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddAuthor(add_author_body)) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index f665a5bd..7803adee 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -3,7 +3,9 @@ use log::info; use serde_json::{Value, json}; use super::{NetworkEvent, NetworkResource}; -use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable, ReadarrTaskName}; +use crate::models::readarr_models::{ + AddAuthorBody, AddReadarrRootFolderBody, ReadarrSerdeable, ReadarrTaskName, +}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; @@ -21,6 +23,7 @@ pub mod readarr_network_test_utils; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { + AddAuthor(AddAuthorBody), AddRootFolder(AddReadarrRootFolderBody), AddTag(String), DeleteRootFolder(i64), @@ -47,7 +50,9 @@ pub enum ReadarrEvent { impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { - ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author", + ReadarrEvent::AddAuthor(_) + | ReadarrEvent::GetAuthorDetails(_) + | ReadarrEvent::ListAuthors => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", @@ -79,6 +84,7 @@ impl Network<'_, '_> { readarr_event: ReadarrEvent, ) -> Result { match readarr_event { + ReadarrEvent::AddAuthor(body) => self.add_author(body).await.map(ReadarrSerdeable::from), ReadarrEvent::AddRootFolder(add_root_folder_body) => self .add_readarr_root_folder(add_root_folder_body) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 90578f88..3bb4212f 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -2,7 +2,8 @@ #[allow(dead_code)] pub mod test_utils { use crate::models::readarr_models::{ - AddAuthorSearchResult, Author, AuthorStatus, NewItemMonitorType, + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, MonitorType, + NewItemMonitorType, }; use crate::models::servarr_models::RootFolder; @@ -81,4 +82,22 @@ pub mod test_utils { unmapped_folders: None, } } + + pub fn add_author_body() -> AddAuthorBody { + AddAuthorBody { + foreign_author_id: "test-foreign-id".to_owned(), + author_name: "Test Author".to_owned(), + monitored: true, + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + tags: Vec::default(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddAuthorOptions { + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + search_for_missing_books: true, + }, + } + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index a392a3df..adfa3dbc 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::app::App; - use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable}; + use crate::models::readarr_models::{AddAuthorBody, AddReadarrRootFolderBody, ReadarrSerdeable}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; @@ -13,6 +13,7 @@ mod tests { use tokio::sync::Mutex; #[rstest] + #[case(ReadarrEvent::AddAuthor(AddAuthorBody::default()), "/author")] #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] From fcfc29894e2b26e753d38d11369573974f1a8dbd Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 09:43:07 -0600 Subject: [PATCH 030/170] feat(readarr): edit Readarr authors and expose the command on the CLI --- src/cli/readarr/edit_command_handler.rs | 145 ++++++ src/cli/readarr/edit_command_handler_tests.rs | 480 ++++++++++++++++++ src/cli/readarr/mod.rs | 12 + src/models/readarr_models.rs | 15 + .../readarr_network/library/authors/mod.rs | 125 ++++- .../authors/readarr_authors_network_tests.rs | 240 ++++++++- src/network/readarr_network/mod.rs | 8 +- .../readarr_network/readarr_network_tests.rs | 5 +- 8 files changed, 1024 insertions(+), 6 deletions(-) create mode 100644 src/cli/readarr/edit_command_handler.rs create mode 100644 src/cli/readarr/edit_command_handler_tests.rs diff --git a/src/cli/readarr/edit_command_handler.rs b/src/cli/readarr/edit_command_handler.rs new file mode 100644 index 00000000..0e8c808d --- /dev/null +++ b/src/cli/readarr/edit_command_handler.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::{ArgAction, ArgGroup, Subcommand}; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command, mutex_flags_or_option}, + models::readarr_models::{EditAuthorParams, NewItemMonitorType}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "edit_command_handler_tests.rs"] +mod edit_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrEditCommand { + #[command( + about = "Edit preferences for the specified author", + group( + ArgGroup::new("edit_author") + .args([ + "enable_monitoring", + "disable_monitoring", + "monitor_new_items", + "quality_profile_id", + "metadata_profile_id", + "root_folder_path", + "tag", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Author { + #[arg( + long, + help = "The ID of the author whose settings you want to edit", + required = true + )] + author_id: i64, + #[arg( + long, + help = "Enable monitoring of this author in Readarr so Readarr will automatically download books from this author if they are available", + conflicts_with = "disable_monitoring" + )] + enable_monitoring: bool, + #[arg( + long, + help = "Disable monitoring of this author so Readarr does not automatically download books from this author if they are available", + conflicts_with = "enable_monitoring" + )] + disable_monitoring: bool, + #[arg( + long, + help = "How Readarr should monitor new books from this author", + value_enum + )] + monitor_new_items: Option, + #[arg(long, help = "The ID of the quality profile to use for this author")] + quality_profile_id: Option, + #[arg(long, help = "The ID of the metadata profile to use for this author")] + metadata_profile_id: Option, + #[arg( + long, + help = "The root folder path where all author data and metadata should live" + )] + root_folder_path: Option, + #[arg( + long, + help = "Tag IDs to tag this author with", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg(long, help = "Clear all tags on this author", conflicts_with = "tag")] + clear_tags: bool, + }, +} + +impl From for Command { + fn from(value: ReadarrEditCommand) -> Self { + Command::Readarr(ReadarrCommand::Edit(value)) + } +} + +pub(super) struct ReadarrEditCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrEditCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrEditCommand> for ReadarrEditCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: ReadarrEditCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrEditCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrEditCommand::Author { + author_id, + enable_monitoring, + disable_monitoring, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tag, + clear_tags, + } => { + let monitored_value = mutex_flags_or_option(enable_monitoring, disable_monitoring); + let edit_author_params = EditAuthorParams { + author_id, + monitored: monitored_value, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags: tag, + tag_input_string: None, + clear_tags, + }; + + self + .network + .handle_network_event(ReadarrEvent::EditAuthor(edit_author_params).into()) + .await?; + "Author updated".to_owned() + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/edit_command_handler_tests.rs b/src/cli/readarr/edit_command_handler_tests.rs new file mode 100644 index 00000000..80313dcb --- /dev/null +++ b/src/cli/readarr/edit_command_handler_tests.rs @@ -0,0 +1,480 @@ +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser, error::ErrorKind}; + + use crate::{ + Cli, + cli::{ + Command, + readarr::{ReadarrCommand, edit_command_handler::ReadarrEditCommand}, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_edit_command_from() { + let command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: None, + clear_tags: false, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Edit(command))); + } + + mod cli { + use super::*; + use crate::models::readarr_models::NewItemMonitorType; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[test] + fn test_edit_author_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "edit", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_author_with_author_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_author_author_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "not_a_number", + "--enable-monitoring", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_edit_author_monitoring_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--enable-monitoring", + "--disable-monitoring", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_author_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--tag", + "1", + "--clear-tags", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_author_assert_argument_flags_require_args( + #[values( + "--monitor-new-items", + "--quality-profile-id", + "--metadata-profile-id", + "--root-folder-path", + "--tag" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_author_monitor_new_items_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--monitor-new-items", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_author_only_requires_at_least_one_argument_plus_author_id() { + let expected_args = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: Some("/nfs/books".to_owned()), + tag: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--root-folder-path", + "/nfs/books", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_author_tag_argument_is_repeatable() { + let expected_args = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_author_all_arguments_defined() { + let expected_args = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: true, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--enable-monitoring", + "--monitor-new-items", + "new", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + "--root-folder-path", + "/nfs/books", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::readarr::edit_command_handler::{ + ReadarrEditCommand, ReadarrEditCommandHandler, + }; + use crate::models::Serdeable; + use crate::models::readarr_models::{EditAuthorParams, NewItemMonitorType, ReadarrSerdeable}; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_edit_author_command() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(true), + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: true, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_author_command_handles_disable_monitoring_flag_properly() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: true, + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_author_command_no_monitoring_boolean_flags_returns_none_value() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: None, + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_author_command_clear_tags() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: None, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tags: None, + tag_input_string: None, + clear_tags: true, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: None, + clear_tags: true, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 37e47506..b57cd562 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -4,6 +4,7 @@ use add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; use anyhow::Result; use clap::Subcommand; use delete_command_handler::{ReadarrDeleteCommand, ReadarrDeleteCommandHandler}; +use edit_command_handler::{ReadarrEditCommand, ReadarrEditCommandHandler}; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use tokio::sync::Mutex; @@ -15,6 +16,7 @@ use crate::{app::App, network::NetworkTrait}; mod add_command_handler; mod delete_command_handler; +mod edit_command_handler; mod get_command_handler; mod list_command_handler; @@ -34,6 +36,11 @@ pub enum ReadarrCommand { about = "Commands to delete resources from your Readarr instance" )] Delete(ReadarrDeleteCommand), + #[command( + subcommand, + about = "Commands to edit resources in your Readarr instance" + )] + Edit(ReadarrEditCommand), #[command( subcommand, about = "Commands to fetch details of the resources in your Readarr instance" @@ -102,6 +109,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::Edit(edit_command) => { + ReadarrEditCommandHandler::with(self.app, edit_command, self.network) + .handle() + .await? + } ReadarrCommand::Get(get_command) => { ReadarrGetCommandHandler::with(self.app, get_command, self.network) .handle() diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index e54051bb..600571af 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -243,6 +243,21 @@ pub struct AddAuthorOptions { pub search_for_missing_books: bool, } +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditAuthorParams { + pub author_id: i64, + pub monitored: Option, + pub monitor_new_items: Option, + pub quality_profile_id: Option, + pub metadata_profile_id: Option, + pub root_folder_path: Option, + pub tags: Option>, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, + pub clear_tags: bool, +} + #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddReadarrRootFolderBody { diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 1938aa8d..5e44a6db 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -1,9 +1,11 @@ use anyhow::Result; -use log::info; -use serde_json::Value; +use log::{debug, info}; +use serde_json::{Value, json}; use crate::models::Route; -use crate::models::readarr_models::{AddAuthorBody, AddAuthorSearchResult, Author}; +use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorSearchResult, Author, EditAuthorParams, +}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; use crate::network::readarr_network::ReadarrEvent; @@ -43,6 +45,123 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn edit_author( + &mut self, + mut edit_author_params: EditAuthorParams, + ) -> Result<()> { + info!("Editing Readarr author"); + if let Some(tag_input_str) = edit_author_params.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + edit_author_params.tags = Some(tag_ids_vec); + } + let author_id = edit_author_params.author_id; + let detail_event = ReadarrEvent::GetAuthorDetails(author_id); + let event = ReadarrEvent::EditAuthor(EditAuthorParams::default()); + info!("Fetching author details for author with ID: {author_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{author_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_author_body, _| { + response = detailed_author_body.to_string() + }) + .await?; + + info!("Constructing edit author body"); + + let mut detailed_author_body: Value = serde_json::from_str(&response)?; + let ( + monitored, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags, + ) = { + let monitored = edit_author_params.monitored.unwrap_or( + detailed_author_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored'"), + ); + let monitor_new_items = edit_author_params.monitor_new_items.unwrap_or_else(|| { + serde_json::from_value(detailed_author_body["monitorNewItems"].clone()) + .expect("Unable to deserialize 'monitorNewItems'") + }); + let quality_profile_id = edit_author_params.quality_profile_id.unwrap_or_else(|| { + detailed_author_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let metadata_profile_id = edit_author_params.metadata_profile_id.unwrap_or_else(|| { + detailed_author_body["metadataProfileId"] + .as_i64() + .expect("Unable to deserialize 'metadataProfileId'") + }); + let root_folder_path = edit_author_params.root_folder_path.unwrap_or_else(|| { + detailed_author_body["path"] + .as_str() + .expect("Unable to deserialize 'path'") + .to_owned() + }); + let tags = if edit_author_params.clear_tags { + vec![] + } else { + edit_author_params.tags.unwrap_or( + detailed_author_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + + ( + monitored, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags, + ) + }; + + *detailed_author_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_author_body.get_mut("monitorNewItems").unwrap() = json!(monitor_new_items); + *detailed_author_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); + *detailed_author_body.get_mut("metadataProfileId").unwrap() = json!(metadata_profile_id); + *detailed_author_body.get_mut("path").unwrap() = json!(root_folder_path); + *detailed_author_body.get_mut("tags").unwrap() = json!(tags); + + debug!("Edit author body: {detailed_author_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_author_body), + Some(format!("/{author_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_author_details( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index 4a2b5453..b4fe8dd9 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -1,10 +1,12 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorSearchResult, Author, ReadarrSerdeable, + AddAuthorBody, AddAuthorSearchResult, Author, EditAuthorParams, NewItemMonitorType, + ReadarrSerdeable, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; + use crate::network::NetworkResource; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ @@ -12,6 +14,7 @@ mod tests { stale_author, }; use bimap::BiMap; + use mockito::Matcher; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -387,4 +390,239 @@ mod tests { mock.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_edit_author_event() { + let mut expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("monitorNewItems").unwrap() = json!("none"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("metadataProfileId").unwrap() = json!(2222); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1111), + metadata_profile_id: Some(2222), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).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.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app + .data + .readarr_data + .authors + .set_items(vec![stale_author()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + let app = app.lock().await; + assert_eq!(app.data.readarr_data.authors.items, vec![stale_author()]); + } + + #[tokio::test] + async fn test_handle_edit_author_event_does_not_overwrite_tags_vec_when_tag_input_string_is_none() + { + let mut expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("monitorNewItems").unwrap() = json!("none"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("metadataProfileId").unwrap() = json!(2222); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1111), + metadata_profile_id: Some(2222), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tags: Some(vec![1, 2]), + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).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.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event_defaults_to_previous_values() { + let edit_author_params = EditAuthorParams { + author_id: 1, + ..EditAuthorParams::default() + }; + let expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event_returns_empty_tags_vec_when_clear_tags_is_true() { + let mut expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + let edit_author_params = EditAuthorParams { + author_id: 1, + clear_tags: true, + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event_failure() { + let edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .status(404) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 7803adee..8c687730 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::{ - AddAuthorBody, AddReadarrRootFolderBody, ReadarrSerdeable, ReadarrTaskName, + AddAuthorBody, AddReadarrRootFolderBody, EditAuthorParams, ReadarrSerdeable, ReadarrTaskName, }; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; @@ -28,6 +28,7 @@ pub enum ReadarrEvent { AddTag(String), DeleteRootFolder(i64), DeleteTag(i64), + EditAuthor(EditAuthorParams), GetAuthorDetails(i64), GetDiskSpace, GetHostConfig, @@ -51,6 +52,7 @@ impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { ReadarrEvent::AddAuthor(_) + | ReadarrEvent::EditAuthor(_) | ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", @@ -98,6 +100,10 @@ impl Network<'_, '_> { .delete_readarr_tag(tag_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::EditAuthor(edit_author_params) => self + .edit_author(edit_author_params) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetAuthorDetails(author_id) => self .get_author_details(author_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index adfa3dbc..30858bf7 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -1,7 +1,9 @@ #[cfg(test)] mod tests { use crate::app::App; - use crate::models::readarr_models::{AddAuthorBody, AddReadarrRootFolderBody, ReadarrSerdeable}; + use crate::models::readarr_models::{ + AddAuthorBody, AddReadarrRootFolderBody, EditAuthorParams, ReadarrSerdeable, + }; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; @@ -14,6 +16,7 @@ mod tests { #[rstest] #[case(ReadarrEvent::AddAuthor(AddAuthorBody::default()), "/author")] + #[case(ReadarrEvent::EditAuthor(EditAuthorParams::default()), "/author")] #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] From ac8a11233ff9c9adcc28a9bd7de54454cbe6986b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 09:54:38 -0600 Subject: [PATCH 031/170] feat(readarr): delete Readarr authors and expose the command on the CLI --- src/cli/readarr/delete_command_handler.rs | 26 ++++ .../readarr/delete_command_handler_tests.rs | 116 +++++++++++++++++- src/models/readarr_models.rs | 8 ++ .../readarr_network/library/authors/mod.rs | 34 ++++- .../authors/readarr_authors_network_tests.rs | 59 ++++++++- src/network/readarr_network/mod.rs | 9 +- .../readarr_network/readarr_network_tests.rs | 3 +- 7 files changed, 249 insertions(+), 6 deletions(-) diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs index a4da3e5c..118a50c6 100644 --- a/src/cli/readarr/delete_command_handler.rs +++ b/src/cli/readarr/delete_command_handler.rs @@ -8,6 +8,7 @@ use super::ReadarrCommand; use crate::{ app::App, cli::{CliCommandHandler, Command}, + models::readarr_models::DeleteParams, network::{NetworkTrait, readarr_network::ReadarrEvent}, }; @@ -17,6 +18,15 @@ mod delete_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrDeleteCommand { + #[command(about = "Delete an author from your Readarr library")] + Author { + #[arg(long, help = "The ID of the author to delete", required = true)] + author_id: i64, + #[arg(long, help = "Delete the author files from disk as well")] + delete_files_from_disk: bool, + #[arg(long, help = "Add a list exclusion for this author")] + add_list_exclusion: bool, + }, #[command(about = "Delete the root folder with the given ID")] RootFolder { #[arg(long, help = "The ID of the root folder to delete", required = true)] @@ -58,6 +68,22 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> async fn handle(self) -> Result { let result = match self.command { + ReadarrDeleteCommand::Author { + author_id, + delete_files_from_disk, + add_list_exclusion, + } => { + let delete_author_params = DeleteParams { + id: author_id, + delete_files: delete_files_from_disk, + add_import_list_exclusion: add_list_exclusion, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteAuthor(delete_author_params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrDeleteCommand::RootFolder { root_folder_id } => { let resp = self .network diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs index 3487186a..4d4ee145 100644 --- a/src/cli/readarr/delete_command_handler_tests.rs +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -24,6 +24,86 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_delete_author_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_author_author_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "author", + "--author-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_author_defaults() { + let expected_args = ReadarrDeleteCommand::Author { + author_id: 1, + delete_files_from_disk: false, + add_list_exclusion: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "author", + "--author-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_author_all_args_defined() { + let expected_args = ReadarrDeleteCommand::Author { + author_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "author", + "--author-id", + "1", + "--delete-files-from-disk", + "--add-list-exclusion", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + #[test] fn test_delete_root_folder_requires_arguments() { let result = @@ -127,13 +207,47 @@ mod tests { ReadarrDeleteCommand, ReadarrDeleteCommandHandler, }; use crate::models::Serdeable; - use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::readarr_models::{DeleteParams, ReadarrSerdeable}; use crate::network::readarr_network::ReadarrEvent; use crate::{ app::App, network::{MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_delete_author_command() { + let expected_delete_author_params = DeleteParams { + id: 1, + delete_files: true, + add_import_list_exclusion: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::DeleteAuthor(expected_delete_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_author_command = ReadarrDeleteCommand::Author { + author_id: 1, + delete_files_from_disk: true, + add_list_exclusion: false, + }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_delete_root_folder_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index 600571af..8b14ad16 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -220,6 +220,14 @@ pub struct AddAuthorSearchResult { pub ratings: Option, } +#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub struct DeleteParams { + pub id: i64, + pub delete_files: bool, + pub add_import_list_exclusion: bool, +} + #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddAuthorBody { diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 5e44a6db..0339da47 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorSearchResult, Author, EditAuthorParams, + AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; @@ -45,6 +45,38 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn delete_author( + &mut self, + delete_author_params: DeleteParams, + ) -> Result<()> { + let event = ReadarrEvent::DeleteAuthor(DeleteParams::default()); + let DeleteParams { + id, + delete_files, + add_import_list_exclusion, + } = delete_author_params; + + info!( + "Deleting Readarr author with ID: {id} with deleteFiles={delete_files} and addImportListExclusion={add_import_list_exclusion}" + ); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + Some(format!( + "deleteFiles={delete_files}&addImportListExclusion={add_import_list_exclusion}" + )), + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn edit_author( &mut self, mut edit_author_params: EditAuthorParams, diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index b4fe8dd9..83ecfcc7 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -1,8 +1,8 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorSearchResult, Author, EditAuthorParams, NewItemMonitorType, - ReadarrSerdeable, + AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, + NewItemMonitorType, ReadarrSerdeable, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; @@ -16,6 +16,7 @@ mod tests { use bimap::BiMap; use mockito::Matcher; use pretty_assertions::assert_eq; + use rstest::rstest; use serde_json::{Value, json}; #[tokio::test] @@ -625,4 +626,58 @@ mod tests { async_edit_server.assert_async().await; assert_err!(result); } + + #[rstest] + #[case(true, false, "deleteFiles=true&addImportListExclusion=false")] + #[case(false, true, "deleteFiles=false&addImportListExclusion=true")] + #[tokio::test] + async fn test_handle_delete_author_event( + #[case] delete_files: bool, + #[case] add_import_list_exclusion: bool, + #[case] expected_query: &str, + ) { + let delete_author_params = DeleteParams { + id: 1, + delete_files, + add_import_list_exclusion, + }; + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .query(expected_query) + .build_for(ReadarrEvent::DeleteAuthor(delete_author_params.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteAuthor(delete_author_params)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_author_event_failure() { + let delete_author_params = DeleteParams { + id: 1, + delete_files: true, + add_import_list_exclusion: false, + }; + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .query("deleteFiles=true&addImportListExclusion=false") + .status(500) + .build_for(ReadarrEvent::DeleteAuthor(delete_author_params.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteAuthor(delete_author_params)) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 8c687730..99738e84 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -4,7 +4,8 @@ use serde_json::{Value, json}; use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::{ - AddAuthorBody, AddReadarrRootFolderBody, EditAuthorParams, ReadarrSerdeable, ReadarrTaskName, + AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, + ReadarrTaskName, }; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; @@ -26,6 +27,7 @@ pub enum ReadarrEvent { AddAuthor(AddAuthorBody), AddRootFolder(AddReadarrRootFolderBody), AddTag(String), + DeleteAuthor(DeleteParams), DeleteRootFolder(i64), DeleteTag(i64), EditAuthor(EditAuthorParams), @@ -52,6 +54,7 @@ impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { ReadarrEvent::AddAuthor(_) + | ReadarrEvent::DeleteAuthor(_) | ReadarrEvent::EditAuthor(_) | ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author", @@ -92,6 +95,10 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::AddTag(tag) => self.add_readarr_tag(tag).await.map(ReadarrSerdeable::from), + ReadarrEvent::DeleteAuthor(delete_author_params) => self + .delete_author(delete_author_params) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::DeleteRootFolder(root_folder_id) => self .delete_readarr_root_folder(root_folder_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 30858bf7..ae5ac1b2 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -2,7 +2,7 @@ mod tests { use crate::app::App; use crate::models::readarr_models::{ - AddAuthorBody, AddReadarrRootFolderBody, EditAuthorParams, ReadarrSerdeable, + AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, }; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; @@ -16,6 +16,7 @@ mod tests { #[rstest] #[case(ReadarrEvent::AddAuthor(AddAuthorBody::default()), "/author")] + #[case(ReadarrEvent::DeleteAuthor(DeleteParams::default()), "/author")] #[case(ReadarrEvent::EditAuthor(EditAuthorParams::default()), "/author")] #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::ListAuthors, "/author")] From f896b65fd937a9274dda86c3515f463cb4261d8d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 10:09:04 -0600 Subject: [PATCH 032/170] feat(readarr): toggle Readarr author monitoring and expose the command on the CLI --- src/cli/readarr/mod.rs | 18 +++++ src/cli/readarr/readarr_command_tests.rs | 54 +++++++++++++ .../readarr_network/library/authors/mod.rs | 65 +++++++++++++++- .../authors/readarr_authors_network_tests.rs | 77 +++++++++++++++++++ src/network/readarr_network/mod.rs | 8 +- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 221 insertions(+), 2 deletions(-) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index b57cd562..529871b4 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -70,6 +70,17 @@ pub enum ReadarrCommand { )] task_name: ReadarrTaskName, }, + #[command( + about = "Toggle monitoring for the specified author corresponding to the given author ID" + )] + ToggleAuthorMonitoring { + #[arg( + long, + help = "The Readarr ID of the author to toggle monitoring on", + required = true + )] + author_id: i64, + }, } impl From for Command { @@ -138,6 +149,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .await?; serde_json::to_string_pretty(&resp)? } + ReadarrCommand::ToggleAuthorMonitoring { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::ToggleAuthorMonitoring(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index c0b25d9e..e702c4db 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -76,6 +76,31 @@ mod tests { assert_ok!(&result); } + + #[test] + fn test_toggle_author_monitoring_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "toggle-author-monitoring"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_toggle_author_monitoring_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "toggle-author-monitoring", + "--author-id", + "1", + ]); + + assert_ok!(&result); + } } mod handler { @@ -149,5 +174,34 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_toggle_author_monitoring_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::ToggleAuthorMonitoring(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let toggle_author_monitoring_command = + ReadarrCommand::ToggleAuthorMonitoring { author_id: 1 }; + + let result = ReadarrCliHandler::with( + &app_arc, + toggle_author_monitoring_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 0339da47..0c9e8ca6 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use log::{debug, info}; +use log::{debug, info, warn}; use serde_json::{Value, json}; use crate::models::Route; @@ -277,4 +277,67 @@ impl Network<'_, '_> { result } + + pub(in crate::network::readarr_network) async fn toggle_author_monitoring( + &mut self, + author_id: i64, + ) -> Result<()> { + let event = ReadarrEvent::ToggleAuthorMonitoring(author_id); + + let detail_event = ReadarrEvent::GetAuthorDetails(author_id); + info!("Toggling author monitoring for author with ID: {author_id}"); + info!("Fetching author details for author with ID: {author_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{author_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_author_body, _| { + response = detailed_author_body.to_string() + }) + .await?; + + info!("Constructing toggle author monitoring body"); + + match serde_json::from_str::(&response) { + Ok(mut detailed_author_body) => { + let monitored = detailed_author_body + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + + *detailed_author_body.get_mut("monitored").unwrap() = json!(!monitored); + + debug!("Toggle author monitoring body: {detailed_author_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_author_body), + Some(format!("/{author_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + Err(_) => { + warn!("Request for detailed author body was interrupted"); + Ok(()) + } + } + } } diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index 83ecfcc7..34199ad8 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -680,4 +680,81 @@ mod tests { mock.assert_async().await; assert_err!(result); } + + #[rstest] + #[case(true, false)] + #[case(false, true)] + #[tokio::test] + async fn test_handle_toggle_author_monitoring_event( + #[case] initial_monitored: bool, + #[case] expected_monitored: bool, + ) { + let mut author_json: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *author_json.get_mut("monitored").unwrap() = json!(initial_monitored); + let mut expected_body = author_json.clone(); + *expected_body.get_mut("monitored").unwrap() = json!(expected_monitored); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(author_json) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleAuthorMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleAuthorMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_toggle_author_monitoring_event_failure() { + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .status(404) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleAuthorMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleAuthorMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 99738e84..fc2fc0cb 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -48,6 +48,7 @@ pub enum ReadarrEvent { ListAuthors, SearchNewAuthor(String), StartTask(ReadarrTaskName), + ToggleAuthorMonitoring(i64), } impl NetworkResource for ReadarrEvent { @@ -57,7 +58,8 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::DeleteAuthor(_) | ReadarrEvent::EditAuthor(_) | ReadarrEvent::GetAuthorDetails(_) - | ReadarrEvent::ListAuthors => "/author", + | ReadarrEvent::ListAuthors + | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", @@ -163,6 +165,10 @@ impl Network<'_, '_> { .start_readarr_task(task_name) .await .map(ReadarrSerdeable::from), + ReadarrEvent::ToggleAuthorMonitoring(author_id) => self + .toggle_author_monitoring(author_id) + .await + .map(ReadarrSerdeable::from), } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index ae5ac1b2..05e1d7ce 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -20,6 +20,7 @@ mod tests { #[case(ReadarrEvent::EditAuthor(EditAuthorParams::default()), "/author")] #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::ListAuthors, "/author")] + #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] From e9b9aa3a146f1c9e6f6c63a689bbb2e66f93d2bf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 10:35:04 -0600 Subject: [PATCH 033/170] feat(readarr): trigger automatic author searches and expose the command on the CLI Posts the AuthorSearch command to the command endpoint with the author ID, queueing an indexer search for that author. The response is returned verbatim and no app state is mutated. Introduces ReadarrCommandBody, the request body shared by the author and book command calls. Both ID fields are omitted from the payload when absent rather than sent as null. --- src/cli/readarr/mod.rs | 18 +++ ...rigger_automatic_search_command_handler.rs | 72 +++++++++++ ..._automatic_search_command_handler_tests.rs | 118 ++++++++++++++++++ src/models/readarr_models.rs | 10 ++ .../readarr_network/library/authors/mod.rs | 23 +++- .../authors/readarr_authors_network_tests.rs | 43 +++++++ src/network/readarr_network/mod.rs | 9 +- .../readarr_network/readarr_network_tests.rs | 1 + 8 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 src/cli/readarr/trigger_automatic_search_command_handler.rs create mode 100644 src/cli/readarr/trigger_automatic_search_command_handler_tests.rs diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 529871b4..b33c5597 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -8,6 +8,9 @@ use edit_command_handler::{ReadarrEditCommand, ReadarrEditCommandHandler}; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use tokio::sync::Mutex; +use trigger_automatic_search_command_handler::{ + ReadarrTriggerAutomaticSearchCommand, ReadarrTriggerAutomaticSearchCommandHandler, +}; use super::{CliCommandHandler, Command}; use crate::models::readarr_models::ReadarrTaskName; @@ -19,6 +22,7 @@ mod delete_command_handler; mod edit_command_handler; mod get_command_handler; mod list_command_handler; +mod trigger_automatic_search_command_handler; #[cfg(test)] #[path = "readarr_command_tests.rs"] @@ -51,6 +55,11 @@ pub enum ReadarrCommand { about = "Commands to list attributes from your Readarr instance" )] List(ReadarrListCommand), + #[command( + subcommand, + about = "Commands to trigger automatic searches for releases of different resources in your Readarr instance" + )] + TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand), #[command(about = "Search for a new author to add to Readarr")] SearchNewAuthor { #[arg( @@ -135,6 +144,15 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => { + ReadarrTriggerAutomaticSearchCommandHandler::with( + self.app, + trigger_automatic_search_command, + self.network, + ) + .handle() + .await? + } ReadarrCommand::SearchNewAuthor { query } => { let resp = self .network diff --git a/src/cli/readarr/trigger_automatic_search_command_handler.rs b/src/cli/readarr/trigger_automatic_search_command_handler.rs new file mode 100644 index 00000000..4722bc6a --- /dev/null +++ b/src/cli/readarr/trigger_automatic_search_command_handler.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +use super::ReadarrCommand; + +#[cfg(test)] +#[path = "trigger_automatic_search_command_handler_tests.rs"] +mod trigger_automatic_search_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrTriggerAutomaticSearchCommand { + #[command(about = "Trigger an automatic search for the author with the specified ID")] + Author { + #[arg( + long, + help = "The ID of the author you want to trigger an automatic search for", + required = true + )] + author_id: i64, + }, +} + +impl From for Command { + fn from(value: ReadarrTriggerAutomaticSearchCommand) -> Self { + Command::Readarr(ReadarrCommand::TriggerAutomaticSearch(value)) + } +} + +pub(super) struct ReadarrTriggerAutomaticSearchCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrTriggerAutomaticSearchCommand> + for ReadarrTriggerAutomaticSearchCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: ReadarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrTriggerAutomaticSearchCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrTriggerAutomaticSearchCommand::Author { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::TriggerAutomaticAuthorSearch(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs b/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs new file mode 100644 index 00000000..d678fa52 --- /dev/null +++ b/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs @@ -0,0 +1,118 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ + ReadarrCommand, + trigger_automatic_search_command_handler::ReadarrTriggerAutomaticSearchCommand, + }, + }; + use clap::CommandFactory; + + #[test] + fn test_readarr_trigger_automatic_search_command_from() { + let command = ReadarrTriggerAutomaticSearchCommand::Author { author_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!( + result, + Command::Readarr(ReadarrCommand::TriggerAutomaticSearch(command)) + ); + } + + mod cli { + use super::*; + use clap::Parser; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_trigger_automatic_author_search_requires_author_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "author", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_author_search_with_author_id() { + let expected_args = ReadarrTriggerAutomaticSearchCommand::Author { author_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "author", + "--author-id", + "1", + ]); + + assert_ok!(&result); + let Some(Command::Readarr(ReadarrCommand::TriggerAutomaticSearch( + trigger_automatic_search_command, + ))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(trigger_automatic_search_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::readarr::trigger_automatic_search_command_handler::{ + ReadarrTriggerAutomaticSearchCommand, ReadarrTriggerAutomaticSearchCommandHandler, + }; + use crate::{app::App, cli::CliCommandHandler}; + use crate::{ + models::{Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_trigger_automatic_author_search_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::TriggerAutomaticAuthorSearch(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let trigger_automatic_search_command = + ReadarrTriggerAutomaticSearchCommand::Author { author_id: 1 }; + + let result = ReadarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index 8b14ad16..bb2f10e6 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -220,6 +220,16 @@ pub struct AddAuthorSearchResult { pub ratings: Option, } +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrCommandBody { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub author_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub book_ids: Option>, +} + #[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub struct DeleteParams { diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 0c9e8ca6..8f763ba0 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, + AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, ReadarrCommandBody, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; @@ -340,4 +340,25 @@ impl Network<'_, '_> { } } } + + pub(in crate::network::readarr_network) async fn trigger_automatic_author_search( + &mut self, + author_id: i64, + ) -> Result { + let event = ReadarrEvent::TriggerAutomaticAuthorSearch(author_id); + info!("Searching indexers for author with ID: {author_id}"); + let body = ReadarrCommandBody { + name: "AuthorSearch".to_owned(), + author_id: Some(author_id), + ..ReadarrCommandBody::default() + }; + + 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/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index 34199ad8..63660863 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -757,4 +757,47 @@ mod tests { async_toggle_server.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_trigger_automatic_author_search_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "AuthorSearch", + "authorId": 1 + })) + .returns(json!({})) + .build_for(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_author_search_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "AuthorSearch", + "authorId": 1 + })) + .returns(json!({"name": "AuthorSearch", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index fc2fc0cb..bc83f1f2 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -49,6 +49,7 @@ pub enum ReadarrEvent { SearchNewAuthor(String), StartTask(ReadarrTaskName), ToggleAuthorMonitoring(i64), + TriggerAutomaticAuthorSearch(i64), } impl NetworkResource for ReadarrEvent { @@ -61,7 +62,9 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", - ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", + ReadarrEvent::GetQueuedEvents + | ReadarrEvent::StartTask(_) + | ReadarrEvent::TriggerAutomaticAuthorSearch(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", @@ -169,6 +172,10 @@ impl Network<'_, '_> { .toggle_author_monitoring(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::TriggerAutomaticAuthorSearch(author_id) => self + .trigger_automatic_author_search(author_id) + .await + .map(ReadarrSerdeable::from), } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 05e1d7ce..0454beba 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -24,6 +24,7 @@ mod tests { #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] + #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] From c82006862e32ec2c09d3d39d4beccd5639217988 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 10:43:37 -0600 Subject: [PATCH 034/170] feat(readarr): refresh and rescan a Readarr author and expose the command on the CLI Posts the RefreshAuthor command to the command endpoint with the author ID, queueing a metadata refresh and a disk scan for that author. The response is returned verbatim and no app state is mutated. Adds the refresh subcommand group to the Readarr CLI. --- src/cli/readarr/mod.rs | 12 +++ src/cli/readarr/refresh_command_handler.rs | 72 +++++++++++++ .../readarr/refresh_command_handler_tests.rs | 102 ++++++++++++++++++ .../readarr_network/library/authors/mod.rs | 21 ++++ .../authors/readarr_authors_network_tests.rs | 43 ++++++++ src/network/readarr_network/mod.rs | 8 +- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/cli/readarr/refresh_command_handler.rs create mode 100644 src/cli/readarr/refresh_command_handler_tests.rs diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index b33c5597..737ebb4f 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -7,6 +7,7 @@ use delete_command_handler::{ReadarrDeleteCommand, ReadarrDeleteCommandHandler}; use edit_command_handler::{ReadarrEditCommand, ReadarrEditCommandHandler}; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; +use refresh_command_handler::{ReadarrRefreshCommand, ReadarrRefreshCommandHandler}; use tokio::sync::Mutex; use trigger_automatic_search_command_handler::{ ReadarrTriggerAutomaticSearchCommand, ReadarrTriggerAutomaticSearchCommandHandler, @@ -22,6 +23,7 @@ mod delete_command_handler; mod edit_command_handler; mod get_command_handler; mod list_command_handler; +mod refresh_command_handler; mod trigger_automatic_search_command_handler; #[cfg(test)] @@ -55,6 +57,11 @@ pub enum ReadarrCommand { about = "Commands to list attributes from your Readarr instance" )] List(ReadarrListCommand), + #[command( + subcommand, + about = "Commands to refresh the data in your Readarr instance" + )] + Refresh(ReadarrRefreshCommand), #[command( subcommand, about = "Commands to trigger automatic searches for releases of different resources in your Readarr instance" @@ -144,6 +151,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::Refresh(refresh_command) => { + ReadarrRefreshCommandHandler::with(self.app, refresh_command, self.network) + .handle() + .await? + } ReadarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => { ReadarrTriggerAutomaticSearchCommandHandler::with( self.app, diff --git a/src/cli/readarr/refresh_command_handler.rs b/src/cli/readarr/refresh_command_handler.rs new file mode 100644 index 00000000..723e27c1 --- /dev/null +++ b/src/cli/readarr/refresh_command_handler.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +use super::ReadarrCommand; + +#[cfg(test)] +#[path = "refresh_command_handler_tests.rs"] +mod refresh_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrRefreshCommand { + #[command(about = "Refresh author data and scan disk for the author with the given ID")] + Author { + #[arg( + long, + help = "The ID of the author to refresh information on and to scan the disk for", + required = true + )] + author_id: i64, + }, +} + +impl From for Command { + fn from(value: ReadarrRefreshCommand) -> Self { + Command::Readarr(ReadarrCommand::Refresh(value)) + } +} + +pub(super) struct ReadarrRefreshCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrRefreshCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrRefreshCommand> + for ReadarrRefreshCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: ReadarrRefreshCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrRefreshCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrRefreshCommand::Author { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::UpdateAndScanAuthor(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/refresh_command_handler_tests.rs b/src/cli/readarr/refresh_command_handler_tests.rs new file mode 100644 index 00000000..0d8d4141 --- /dev/null +++ b/src/cli/readarr/refresh_command_handler_tests.rs @@ -0,0 +1,102 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ReadarrCommand, refresh_command_handler::ReadarrRefreshCommand}, + }; + use clap::CommandFactory; + + #[test] + fn test_readarr_refresh_command_from() { + let command = ReadarrRefreshCommand::Author { author_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Refresh(command))); + } + + mod cli { + use super::*; + use clap::Parser; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_refresh_author_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "refresh", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_refresh_author_with_author_id() { + let expected_args = ReadarrRefreshCommand::Author { author_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "refresh", + "author", + "--author-id", + "1", + ]); + + assert_ok!(&result); + let Some(Command::Readarr(ReadarrCommand::Refresh(refresh_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(refresh_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::readarr::refresh_command_handler::{ + ReadarrRefreshCommand, ReadarrRefreshCommandHandler, + }; + use crate::{app::App, cli::CliCommandHandler}; + use crate::{ + models::{Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_refresh_author_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::UpdateAndScanAuthor(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_command = ReadarrRefreshCommand::Author { author_id: 1 }; + + let result = ReadarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 8f763ba0..90fbbd19 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -361,4 +361,25 @@ impl Network<'_, '_> { .handle_request::(request_props, |_, _| ()) .await } + + pub(in crate::network::readarr_network) async fn update_and_scan_author( + &mut self, + author_id: i64, + ) -> Result { + let event = ReadarrEvent::UpdateAndScanAuthor(author_id); + info!("Updating and scanning author with ID: {author_id}"); + let body = ReadarrCommandBody { + name: "RefreshAuthor".to_owned(), + author_id: Some(author_id), + ..ReadarrCommandBody::default() + }; + + 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/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index 63660863..c8d57e71 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -800,4 +800,47 @@ mod tests { mock.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_update_and_scan_author_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor", + "authorId": 1 + })) + .returns(json!({})) + .build_for(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_update_and_scan_author_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor", + "authorId": 1 + })) + .returns(json!({"name": "RefreshAuthor", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index bc83f1f2..fa615678 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -50,6 +50,7 @@ pub enum ReadarrEvent { StartTask(ReadarrTaskName), ToggleAuthorMonitoring(i64), TriggerAutomaticAuthorSearch(i64), + UpdateAndScanAuthor(i64), } impl NetworkResource for ReadarrEvent { @@ -64,7 +65,8 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) - | ReadarrEvent::TriggerAutomaticAuthorSearch(_) => "/command", + | ReadarrEvent::TriggerAutomaticAuthorSearch(_) + | ReadarrEvent::UpdateAndScanAuthor(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", @@ -176,6 +178,10 @@ impl Network<'_, '_> { .trigger_automatic_author_search(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::UpdateAndScanAuthor(author_id) => self + .update_and_scan_author(author_id) + .await + .map(ReadarrSerdeable::from), } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 0454beba..a67d8217 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -25,6 +25,7 @@ mod tests { #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")] + #[case(ReadarrEvent::UpdateAndScanAuthor(1), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] From 9572b68f97cd36ab08cff37b46a3d0b971c3f034 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 10:50:53 -0600 Subject: [PATCH 035/170] feat(readarr): refresh all Readarr authors and expose the command on the CLI Posts the RefreshAuthor command to the command endpoint with no author ID, which is how Readarr distinguishes a library wide refresh from a single author refresh. The response is returned verbatim and no app state is mutated. --- src/cli/readarr/refresh_command_handler.rs | 9 ++++ .../readarr/refresh_command_handler_tests.rs | 30 ++++++++++++++ .../readarr_network/library/authors/mod.rs | 17 ++++++++ .../authors/readarr_authors_network_tests.rs | 41 +++++++++++++++++++ src/network/readarr_network/mod.rs | 3 ++ .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 101 insertions(+) diff --git a/src/cli/readarr/refresh_command_handler.rs b/src/cli/readarr/refresh_command_handler.rs index 723e27c1..fe186b2a 100644 --- a/src/cli/readarr/refresh_command_handler.rs +++ b/src/cli/readarr/refresh_command_handler.rs @@ -18,6 +18,8 @@ mod refresh_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrRefreshCommand { + #[command(about = "Refresh all author data for all authors in your Readarr library")] + AllAuthors, #[command(about = "Refresh author data and scan disk for the author with the given ID")] Author { #[arg( @@ -58,6 +60,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrRefreshCommand> async fn handle(self) -> Result { let result = match self.command { + ReadarrRefreshCommand::AllAuthors => { + let resp = self + .network + .handle_network_event(ReadarrEvent::UpdateAllAuthors.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrRefreshCommand::Author { author_id } => { let resp = self .network diff --git a/src/cli/readarr/refresh_command_handler_tests.rs b/src/cli/readarr/refresh_command_handler_tests.rs index 0d8d4141..b5e3182e 100644 --- a/src/cli/readarr/refresh_command_handler_tests.rs +++ b/src/cli/readarr/refresh_command_handler_tests.rs @@ -24,6 +24,14 @@ mod tests { use clap::error::ErrorKind; use pretty_assertions::assert_eq; + #[test] + fn test_refresh_all_authors_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "refresh", "all-authors"]); + + assert_ok!(&result); + } + #[test] fn test_refresh_author_requires_author_id() { let result = @@ -75,6 +83,28 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; + #[tokio::test] + async fn test_handle_refresh_all_authors_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::UpdateAllAuthors.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_command = ReadarrRefreshCommand::AllAuthors; + + let result = ReadarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_refresh_author_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 90fbbd19..90d50998 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -362,6 +362,23 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn update_all_authors(&mut self) -> Result { + info!("Updating all authors"); + let event = ReadarrEvent::UpdateAllAuthors; + let body = ReadarrCommandBody { + name: "RefreshAuthor".to_owned(), + ..ReadarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn update_and_scan_author( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index c8d57e71..812e4658 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -843,4 +843,45 @@ mod tests { mock.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_update_all_authors_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor" + })) + .returns(json!({})) + .build_for(ReadarrEvent::UpdateAllAuthors) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAllAuthors) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_update_all_authors_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor" + })) + .returns(json!({"name": "RefreshAuthor", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::UpdateAllAuthors) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAllAuthors) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index fa615678..1f456dbf 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -50,6 +50,7 @@ pub enum ReadarrEvent { StartTask(ReadarrTaskName), ToggleAuthorMonitoring(i64), TriggerAutomaticAuthorSearch(i64), + UpdateAllAuthors, UpdateAndScanAuthor(i64), } @@ -66,6 +67,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) | ReadarrEvent::TriggerAutomaticAuthorSearch(_) + | ReadarrEvent::UpdateAllAuthors | ReadarrEvent::UpdateAndScanAuthor(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", @@ -178,6 +180,7 @@ impl Network<'_, '_> { .trigger_automatic_author_search(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::UpdateAllAuthors => self.update_all_authors().await.map(ReadarrSerdeable::from), ReadarrEvent::UpdateAndScanAuthor(author_id) => self .update_and_scan_author(author_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index a67d8217..0e71392e 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -25,6 +25,7 @@ mod tests { #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")] + #[case(ReadarrEvent::UpdateAllAuthors, "/command")] #[case(ReadarrEvent::UpdateAndScanAuthor(1), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] From 8752a85db5718667349f9c3e598a1c3179b5fcd5 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 11:11:24 -0600 Subject: [PATCH 036/170] feat(readarr): fetch Readarr author history and expose it on the CLI Add the GetAuthorHistory event, backed by GET /history/author?authorId=N, which returns a flat array of history records for a single author. The fetcher mirrors the artist-history equivalent, sorting by id and carrying the sort-prompt route guard so a refresh landing while the sort prompt is open cannot clobber an in-progress sort selection. Expose it as `readarr list author-history --author-id N`. --- src/cli/readarr/list_command_handler.rs | 16 ++ src/cli/readarr/list_command_handler_tests.rs | 59 +++++ .../readarr_network/library/authors/mod.rs | 36 +++ .../authors/readarr_authors_network_tests.rs | 246 +++++++++++++++++- src/network/readarr_network/mod.rs | 6 + .../readarr_network_test_utils.rs | 44 +++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 402 insertions(+), 6 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 2ddf9180..b7cee84c 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -17,6 +17,15 @@ mod list_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrListCommand { + #[command(about = "Fetch all history events for the author with the given ID")] + AuthorHistory { + #[arg( + long, + help = "The Readarr ID of the author whose history you wish to fetch", + required = true + )] + author_id: i64, + }, #[command(about = "List all authors in your Readarr library")] Authors, #[command(about = "List disk space details for all provisioned root folders in Readarr")] @@ -74,6 +83,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman async fn handle(self) -> Result { let result = match self.command { + ReadarrListCommand::AuthorHistory { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetAuthorHistory(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Authors => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 247d7d34..9719b579 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -52,6 +52,39 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); } + #[test] + fn test_list_author_history_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "author-history"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_author_history_success() { + let expected_args = ReadarrListCommand::AuthorHistory { author_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "author-history", + "--author-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(author_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(author_command, expected_args); + } + #[test] fn test_list_logs_default_values() { let expected_args = ReadarrListCommand::Logs { @@ -111,6 +144,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_author_history_command() { + let expected_author_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAuthorHistory(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_author_history_command = ReadarrListCommand::AuthorHistory { author_id: 1 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_author_history_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_disk_space_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 90d50998..cd3d1dd0 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -5,6 +5,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{ AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, ReadarrCommandBody, + ReadarrHistoryItem, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; @@ -216,6 +217,41 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_readarr_author_history( + &mut self, + author_id: i64, + ) -> Result> { + info!("Fetching Readarr author history for author with ID: {author_id}"); + let event = ReadarrEvent::GetAuthorHistory(author_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("authorId={author_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut history_vec, mut app| { + let is_sorting = matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::AuthorHistorySortPrompt, _) + ); + + let author_history = &mut app.data.readarr_data.author_history; + + if !is_sorting { + history_vec.sort_by_key(|a| a.id); + author_history.set_items(history_vec); + author_history.apply_sorting_toggle(false); + } + }) + .await + } + pub(in crate::network::readarr_network) async fn list_authors(&mut self) -> Result> { info!("Fetching Readarr authors"); let event = ReadarrEvent::ListAuthors; diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index 812e4658..88c94e6c 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -2,16 +2,16 @@ mod tests { use crate::models::readarr_models::{ AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, - NewItemMonitorType, ReadarrSerdeable, + NewItemMonitorType, ReadarrHistoryItem, ReadarrSerdeable, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; - use crate::models::stateful_table::StatefulTable; + use crate::models::stateful_table::{SortOption, StatefulTable}; use crate::network::NetworkResource; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, add_author_body, stale_add_author_search_result, - stale_author, + ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, add_author_body, readarr_history_item, + stale_add_author_search_result, stale_author, stale_readarr_history_item, }; use bimap::BiMap; use mockito::Matcher; @@ -76,6 +76,244 @@ mod tests { assert_err!(result); } + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_author_history_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let history_json = json!([{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }, + ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1") + .build_for(ReadarrEvent::GetAuthorHistory(1)) + .await; + if use_custom_sorting { + let cmp_fn = |a: &ReadarrHistoryItem, b: &ReadarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .readarr_data + .author_history + .sorting(vec![history_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.author_history.sort_asc = true; + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorHistory(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, response); + let app = app.lock().await; + assert_eq!( + app.data.readarr_data.author_history.items, + expected_history_items + ); + assert!(app.data.readarr_data.author_history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_author_history_event_no_op_when_user_is_selecting_sort_options() + { + let history_json = json!([{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1") + .build_for(ReadarrEvent::GetAuthorHistory(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.author_history.sort_asc = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistorySortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorHistory(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, response); + let app = app.lock().await; + assert_is_empty!(app.data.readarr_data.author_history); + assert!(app.data.readarr_data.author_history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_author_history_event_empty_response() { + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .query("authorId=1") + .build_for(ReadarrEvent::GetAuthorHistory(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .author_history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorHistory(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_is_empty!(history_items); + assert_is_empty!(app.lock().await.data.readarr_data.author_history); + } + + #[tokio::test] + async fn test_handle_get_readarr_author_history_event_failure() { + let history_json = json!([{ + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .status(500) + .query("authorId=1") + .build_for(ReadarrEvent::GetAuthorHistory(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .author_history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorHistory(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.author_history.items, + stale_history_items + ); + } + #[tokio::test] async fn test_handle_list_authors_event() { let authors_json = json!([ diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 1f456dbf..a5780f4c 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -32,6 +32,7 @@ pub enum ReadarrEvent { DeleteTag(i64), EditAuthor(EditAuthorParams), GetAuthorDetails(i64), + GetAuthorHistory(i64), GetDiskSpace, GetHostConfig, GetLogs(u64), @@ -72,6 +73,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", + ReadarrEvent::GetAuthorHistory(_) => "/history/author", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -124,6 +126,10 @@ impl Network<'_, '_> { .get_author_details(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetAuthorHistory(author_id) => self + .get_readarr_author_history(author_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetDiskSpace => self .get_readarr_diskspace() .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 3bb4212f..483b95d9 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -3,9 +3,10 @@ pub mod test_utils { use crate::models::readarr_models::{ AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, MonitorType, - NewItemMonitorType, + NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, }; - use crate::models::servarr_models::RootFolder; + use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; + use chrono::DateTime; pub const AUTHOR_JSON: &str = r#"{ "id": 1, @@ -83,6 +84,45 @@ pub mod test_utils { } } + pub fn stale_readarr_history_item() -> ReadarrHistoryItem { + ReadarrHistoryItem { + id: 99, + source_title: "Stale source title".into(), + ..ReadarrHistoryItem::default() + } + } + + pub fn quality_wrapper() -> QualityWrapper { + QualityWrapper { quality: quality() } + } + + pub fn quality() -> Quality { + Quality { + name: "AZW3".to_string(), + } + } + + pub fn readarr_history_item() -> ReadarrHistoryItem { + ReadarrHistoryItem { + id: 1, + author_id: 1, + book_id: 1, + source_title: "Test source title".into(), + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + event_type: ReadarrHistoryEventType::Grabbed, + data: readarr_history_data(), + } + } + + pub fn readarr_history_data() -> ReadarrHistoryData { + ReadarrHistoryData { + dropped_path: Some("/nfs/nzbget/completed/books/Something/cool.azw3".to_owned()), + imported_path: Some("/nfs/books/Test Author/Book 1/Cool.azw3".to_owned()), + ..ReadarrHistoryData::default() + } + } + pub fn add_author_body() -> AddAuthorBody { AddAuthorBody { foreign_author_id: "test-foreign-id".to_owned(), diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 0e71392e..d22a37de 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -31,6 +31,7 @@ mod tests { #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] + #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] From 41830cc072681032c1d881036a3857eecc50d0aa Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 14:38:51 -0600 Subject: [PATCH 037/170] feat(readarr): fetch Readarr books and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 16 +++ src/cli/readarr/list_command_handler_tests.rs | 51 ++++++++ .../readarr_network/library/books/mod.rs | 37 ++++++ .../books/readarr_books_network_tests.rs | 120 ++++++++++++++++++ src/network/readarr_network/library/mod.rs | 1 + src/network/readarr_network/mod.rs | 5 + .../readarr_network_test_utils.rs | 35 ++++- .../readarr_network/readarr_network_tests.rs | 1 + 8 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 src/network/readarr_network/library/books/mod.rs create mode 100644 src/network/readarr_network/library/books/readarr_books_network_tests.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index b7cee84c..a7aaeb82 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -28,6 +28,15 @@ pub enum ReadarrListCommand { }, #[command(about = "List all authors in your Readarr library")] Authors, + #[command(about = "List all books for the author with the given ID")] + Books { + #[arg( + long, + help = "The Readarr ID of the author whose books you want to list", + required = true + )] + author_id: i64, + }, #[command(about = "List disk space details for all provisioned root folders in Readarr")] DiskSpace, #[command(about = "Fetch Readarr logs")] @@ -97,6 +106,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Books { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBooks(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::DiskSpace => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 9719b579..a53e3fa0 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -85,6 +85,32 @@ mod tests { assert_eq!(author_command, expected_args); } + #[test] + fn test_list_books_requires_author_id() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "books"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_books_success() { + let expected_args = ReadarrListCommand::Books { author_id: 1 }; + let result = + Cli::try_parse_from(["managarr", "readarr", "list", "books", "--author-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(books_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(books_command, expected_args); + } + #[test] fn test_list_logs_default_values() { let expected_args = ReadarrListCommand::Logs { @@ -170,6 +196,31 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_books_command() { + let expected_author_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBooks(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_books_command = ReadarrListCommand::Books { author_id: 1 }; + + let result = ReadarrListCommandHandler::with(&app_arc, list_books_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_disk_space_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs new file mode 100644 index 00000000..74a347ef --- /dev/null +++ b/src/network/readarr_network/library/books/mod.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use log::info; + +use crate::models::readarr_models::Book; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_books_network_tests.rs"] +mod readarr_books_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_books( + &mut self, + author_id: i64, + ) -> Result> { + info!("Fetching books for Readarr author with ID: {author_id}"); + let event = ReadarrEvent::GetBooks(author_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("authorId={author_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut books_vec, mut app| { + books_vec.sort_by_key(|a| a.id); + app.data.readarr_data.books.set_items(books_vec); + }) + .await + } +} diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs new file mode 100644 index 00000000..d44c6b2a --- /dev/null +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -0,0 +1,120 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{Book, ReadarrSerdeable}; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + BOOK_JSON, stale_book, + }; + use pretty_assertions::assert_eq; + use serde_json::{Value, json}; + + #[tokio::test] + async fn test_handle_get_books_event() { + let books_json = json!([ + { + "id": 3, + "title": "Third Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id-3", + "monitored": true, + "anyEditionOk": true, + "pageCount": 320, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-03-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + }, + { + "id": 1, + "title": "First Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id-1", + "monitored": true, + "anyEditionOk": true, + "pageCount": 662, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-01-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + }, + { + "id": 2, + "title": "Second Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id-2", + "monitored": false, + "anyEditionOk": true, + "pageCount": 128, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-02-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + } + ]); + let response: Vec = serde_json::from_value(books_json.clone()).unwrap(); + let mut sorted_books = response.clone(); + sorted_books.sort_by_key(|book| book.id); + let (mock, app, _server) = MockServarrApi::get() + .returns(books_json) + .query("authorId=1") + .build_for(ReadarrEvent::GetBooks(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBooks(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Books(books) = result.unwrap() else { + panic!("Expected Books") + }; + + assert_eq!(books, response); + assert_eq!(app.lock().await.data.readarr_data.books.items, sorted_books); + } + + #[tokio::test] + async fn test_handle_get_books_event_failure() { + let stale_books = vec![stale_book()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([serde_json::from_str::(BOOK_JSON).unwrap()])) + .status(500) + .query("authorId=1") + .build_for(ReadarrEvent::GetBooks(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.books.set_items(stale_books.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBooks(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!(app.lock().await.data.readarr_data.books.items, stale_books); + } +} diff --git a/src/network/readarr_network/library/mod.rs b/src/network/readarr_network/library/mod.rs index 4f299636..c2fc5060 100644 --- a/src/network/readarr_network/library/mod.rs +++ b/src/network/readarr_network/library/mod.rs @@ -1 +1,2 @@ mod authors; +mod books; diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index a5780f4c..83189d3a 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -33,6 +33,7 @@ pub enum ReadarrEvent { EditAuthor(EditAuthorParams), GetAuthorDetails(i64), GetAuthorHistory(i64), + GetBooks(i64), GetDiskSpace, GetHostConfig, GetLogs(u64), @@ -65,6 +66,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", + ReadarrEvent::GetBooks(_) => "/book", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) | ReadarrEvent::TriggerAutomaticAuthorSearch(_) @@ -130,6 +132,9 @@ impl Network<'_, '_> { .get_readarr_author_history(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBooks(author_id) => { + self.get_books(author_id).await.map(ReadarrSerdeable::from) + } ReadarrEvent::GetDiskSpace => self .get_readarr_diskspace() .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 483b95d9..97240eb9 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -2,8 +2,9 @@ #[allow(dead_code)] pub mod test_utils { use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, MonitorType, - NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, + MonitorType, NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, + ReadarrHistoryItem, }; use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; use chrono::DateTime; @@ -47,6 +48,25 @@ pub mod test_utils { "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } }"#; + pub const BOOK_JSON: &str = r#"{ + "id": 1, + "title": "Test Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id", + "monitored": true, + "anyEditionOk": true, + "pageCount": 662, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-01-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + }"#; + pub fn stale_author() -> Author { Author { id: 99, @@ -65,6 +85,17 @@ pub mod test_utils { } } + pub fn stale_book() -> Book { + Book { + id: 99, + title: "Stale Book".into(), + author_id: 99, + foreign_book_id: "foreign-book-99".to_owned(), + monitored: true, + ..Book::default() + } + } + pub fn stale_add_author_search_result() -> AddAuthorSearchResult { AddAuthorSearchResult { foreign_author_id: "foreign-author-99".to_owned(), diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d22a37de..81139c6e 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -22,6 +22,7 @@ mod tests { #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] + #[case(ReadarrEvent::GetBooks(1), "/book")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")] From 817ebeaf905ff7fcb4c766edb50ff049f24acbd2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 14:51:40 -0600 Subject: [PATCH 038/170] feat(readarr): fetch Readarr book details and expose them on the CLI --- src/cli/readarr/get_command_handler.rs | 16 ++++++ src/cli/readarr/get_command_handler_tests.rs | 52 +++++++++++++++++++ .../readarr_network/library/books/mod.rs | 22 ++++++++ .../books/readarr_books_network_tests.rs | 50 ++++++++++++++++++ src/network/readarr_network/mod.rs | 7 ++- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 147 insertions(+), 1 deletion(-) diff --git a/src/cli/readarr/get_command_handler.rs b/src/cli/readarr/get_command_handler.rs index 91bb322e..65b8639b 100644 --- a/src/cli/readarr/get_command_handler.rs +++ b/src/cli/readarr/get_command_handler.rs @@ -27,6 +27,15 @@ pub enum ReadarrGetCommand { )] author_id: i64, }, + #[command(about = "Get detailed information for the book with the given ID")] + BookDetails { + #[arg( + long, + help = "The Readarr ID of the book whose details you wish to fetch", + required = true + )] + book_id: i64, + }, #[command(about = "Fetch the host config for your Readarr instance")] HostConfig, #[command(about = "Fetch the security config for your Readarr instance")] @@ -69,6 +78,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrGetCommand> for ReadarrGetCommandH .await?; serde_json::to_string_pretty(&resp)? } + ReadarrGetCommand::BookDetails { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookDetails(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrGetCommand::HostConfig => { let resp = self .network diff --git a/src/cli/readarr/get_command_handler_tests.rs b/src/cli/readarr/get_command_handler_tests.rs index d12d7ca4..cc590648 100644 --- a/src/cli/readarr/get_command_handler_tests.rs +++ b/src/cli/readarr/get_command_handler_tests.rs @@ -50,6 +50,32 @@ mod tests { assert_ok!(&result); } + #[test] + fn test_book_details_requires_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "get", "book-details"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_book_details_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "get", + "book-details", + "--book-id", + "1", + ]); + + assert_ok!(&result); + } + #[rstest] fn test_get_commands_have_no_arg_requirements( #[values("host-config", "security-config", "system-status")] subcommand: &str, @@ -103,6 +129,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_get_book_details_command() { + let expected_book_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookDetails(expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_book_details_command = ReadarrGetCommand::BookDetails { book_id: 1 }; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_book_details_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_get_host_config_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index 74a347ef..0412dd87 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -10,6 +10,28 @@ use crate::network::{Network, RequestMethod}; mod readarr_books_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_book_details( + &mut self, + book_id: i64, + ) -> Result { + info!("Fetching details for Readarr book with ID: {book_id}"); + let event = ReadarrEvent::GetBookDetails(book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{book_id}")), + None, + ) + .await; + + self + .handle_request::<(), Book>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_books( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index d44c6b2a..a69b2707 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -9,6 +9,56 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::{Value, json}; + #[tokio::test] + async fn test_handle_get_book_details_event() { + let expected_book: Book = serde_json::from_str(BOOK_JSON).unwrap(); + let stale_books = vec![stale_book()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(BOOK_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.books.set_items(stale_books.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookDetails(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Book(book) = result.unwrap() else { + panic!("Expected Book") + }; + + assert_eq!(book, expected_book); + assert_eq!(app.lock().await.data.readarr_data.books.items, stale_books); + } + + #[tokio::test] + async fn test_handle_get_book_details_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(BOOK_JSON).unwrap()) + .status(500) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookDetails(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_books_event() { let books_json = json!([ diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 83189d3a..cedf396a 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -33,6 +33,7 @@ pub enum ReadarrEvent { EditAuthor(EditAuthorParams), GetAuthorDetails(i64), GetAuthorHistory(i64), + GetBookDetails(i64), GetBooks(i64), GetDiskSpace, GetHostConfig, @@ -66,7 +67,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", - ReadarrEvent::GetBooks(_) => "/book", + ReadarrEvent::GetBookDetails(_) | ReadarrEvent::GetBooks(_) => "/book", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) | ReadarrEvent::TriggerAutomaticAuthorSearch(_) @@ -132,6 +133,10 @@ impl Network<'_, '_> { .get_readarr_author_history(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookDetails(book_id) => self + .get_book_details(book_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetBooks(author_id) => { self.get_books(author_id).await.map(ReadarrSerdeable::from) } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 81139c6e..c3465aab 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -22,6 +22,7 @@ mod tests { #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] + #[case(ReadarrEvent::GetBookDetails(1), "/book")] #[case(ReadarrEvent::GetBooks(1), "/book")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] From 4c6a9ece4edca9771a2f0703bcf634f708df3159 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 14:58:47 -0600 Subject: [PATCH 039/170] feat(readarr): fetch Readarr book editions and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 16 ++ src/cli/readarr/list_command_handler_tests.rs | 60 ++++++ .../readarr_network/library/books/mod.rs | 33 +++- .../books/readarr_books_network_tests.rs | 186 +++++++++++++++++- src/network/readarr_network/mod.rs | 6 + .../readarr_network_test_utils.rs | 32 ++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 330 insertions(+), 4 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index a7aaeb82..fce642af 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -28,6 +28,15 @@ pub enum ReadarrListCommand { }, #[command(about = "List all authors in your Readarr library")] Authors, + #[command(about = "List all editions for the book with the given ID")] + BookEditions { + #[arg( + long, + help = "The Readarr ID of the book whose editions you want to list", + required = true + )] + book_id: i64, + }, #[command(about = "List all books for the author with the given ID")] Books { #[arg( @@ -106,6 +115,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::BookEditions { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookEditions(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Books { author_id } => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index a53e3fa0..ce74d970 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -96,6 +96,40 @@ mod tests { ); } + #[test] + fn test_list_book_editions_requires_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "book-editions"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_book_editions_success() { + let expected_args = ReadarrListCommand::BookEditions { book_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "book-editions", + "--book-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(book_editions_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(book_editions_command, expected_args); + } + #[test] fn test_list_books_success() { let expected_args = ReadarrListCommand::Books { author_id: 1 }; @@ -221,6 +255,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_book_editions_command() { + let expected_book_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookEditions(expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_book_editions_command = ReadarrListCommand::BookEditions { book_id: 1 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_book_editions_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_disk_space_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index 0412dd87..df9b060d 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -1,7 +1,7 @@ use anyhow::Result; use log::info; -use crate::models::readarr_models::Book; +use crate::models::readarr_models::{Book, Edition}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; @@ -32,6 +32,37 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_book_editions( + &mut self, + book_id: i64, + ) -> Result> { + info!("Fetching editions for Readarr book with ID: {book_id}"); + let event = ReadarrEvent::GetBookEditions(book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("bookId={book_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut editions_vec, mut app| { + editions_vec.sort_by_key(|edition| edition.id); + let book_details_modal = app + .data + .readarr_data + .book_details_modal + .get_or_insert_default(); + + book_details_modal.editions.set_items(editions_vec); + }) + .await + } + pub(in crate::network::readarr_network) async fn get_books( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index a69b2707..ac347709 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -1,10 +1,11 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::{Book, ReadarrSerdeable}; + use crate::models::readarr_models::{Book, Edition, ReadarrSerdeable}; + use crate::models::servarr_data::readarr::modals::BookDetailsModal; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - BOOK_JSON, stale_book, + BOOK_JSON, EDITION_JSON, stale_book, stale_edition, }; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -59,6 +60,187 @@ mod tests { assert_err!(result); } + #[tokio::test] + async fn test_handle_get_book_editions_event() { + let editions_json = json!([ + { + "id": 3, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id-3", + "monitored": false, + "isEbook": true, + "title": "Third Edition", + "language": "eng", + "overview": "the third edition", + "format": "Kindle Edition", + "publisher": "Third Publisher", + "pageCount": 320, + "releaseDate": "2023-03-01T00:00:00Z", + "isbn13": "9780000000003", + "asin": "B000000003", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }, + { + "id": 1, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id-1", + "monitored": true, + "isEbook": false, + "title": "First Edition", + "language": "eng", + "overview": "the first edition", + "format": "Hardcover", + "publisher": "First Publisher", + "pageCount": 662, + "releaseDate": "2023-01-01T00:00:00Z", + "isbn13": "9780000000001", + "asin": "B000000001", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }, + { + "id": 2, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id-2", + "monitored": true, + "isEbook": false, + "title": "Second Edition", + "language": "eng", + "overview": "the second edition", + "format": "Paperback", + "publisher": "Second Publisher", + "pageCount": 128, + "releaseDate": "2023-02-01T00:00:00Z", + "isbn13": "9780000000002", + "asin": "B000000002", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + } + ]); + let response: Vec = serde_json::from_value(editions_json.clone()).unwrap(); + let mut sorted_editions = response.clone(); + sorted_editions.sort_by_key(|edition| edition.id); + let (mock, app, _server) = MockServarrApi::get() + .returns(editions_json) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookEditions(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookEditions(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Editions(editions) = result.unwrap() else { + panic!("Expected Editions") + }; + + assert_eq!(editions, response); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .editions + .items, + sorted_editions + ); + } + + #[tokio::test] + async fn test_handle_get_book_editions_event_empty_book_details_modal() { + let expected_editions: Vec = vec![serde_json::from_str(EDITION_JSON).unwrap()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(EDITION_JSON).unwrap()] + )) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookEditions(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookEditions(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Editions(editions) = result.unwrap() else { + panic!("Expected Editions") + }; + + assert_eq!(editions, expected_editions); + + let app = app.lock().await; + + assert_some!(&app.data.readarr_data.book_details_modal); + assert_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .editions + .items, + expected_editions + ); + } + + #[tokio::test] + async fn test_handle_get_book_editions_event_failure() { + let stale_editions = vec![stale_edition()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(EDITION_JSON).unwrap()] + )) + .status(500) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookEditions(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .editions + .set_items(stale_editions.clone()); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookEditions(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .editions + .items, + stale_editions + ); + } + #[tokio::test] async fn test_handle_get_books_event() { let books_json = json!([ diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index cedf396a..e881bbc6 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -34,6 +34,7 @@ pub enum ReadarrEvent { GetAuthorDetails(i64), GetAuthorHistory(i64), GetBookDetails(i64), + GetBookEditions(i64), GetBooks(i64), GetDiskSpace, GetHostConfig, @@ -75,6 +76,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::UpdateAndScanAuthor(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", + ReadarrEvent::GetBookEditions(_) => "/edition", ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetAuthorHistory(_) => "/history/author", ReadarrEvent::GetLogs(_) => "/log", @@ -137,6 +139,10 @@ impl Network<'_, '_> { .get_book_details(book_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookEditions(book_id) => self + .get_book_editions(book_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetBooks(author_id) => { self.get_books(author_id).await.map(ReadarrSerdeable::from) } diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 97240eb9..ebbc299c 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -2,7 +2,7 @@ #[allow(dead_code)] pub mod test_utils { use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, Edition, MonitorType, NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, }; @@ -67,6 +67,24 @@ pub mod test_utils { "grabbed": false }"#; + pub const EDITION_JSON: &str = r#"{ + "id": 1, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id", + "monitored": true, + "isEbook": true, + "title": "Test Edition", + "language": "eng", + "overview": "some interesting description of the edition", + "format": "Paperback", + "publisher": "Test Publisher", + "pageCount": 662, + "releaseDate": "2023-01-01T00:00:00Z", + "isbn13": "9780000000001", + "asin": "B000000001", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }"#; + pub fn stale_author() -> Author { Author { id: 99, @@ -96,6 +114,18 @@ pub mod test_utils { } } + pub fn stale_edition() -> Edition { + Edition { + id: 99, + book_id: 99, + foreign_edition_id: "foreign-edition-99".to_owned(), + monitored: true, + is_ebook: true, + title: "Stale Edition".to_owned(), + ..Edition::default() + } + } + pub fn stale_add_author_search_result() -> AddAuthorSearchResult { AddAuthorSearchResult { foreign_author_id: "foreign-author-99".to_owned(), diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index c3465aab..ea69ec3e 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -32,6 +32,7 @@ mod tests { #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] + #[case(ReadarrEvent::GetBookEditions(1), "/edition")] #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] #[case(ReadarrEvent::GetLogs(500), "/log")] From 634d1fc1b192524fa4276008e08dff5619ed19d7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 15:22:50 -0600 Subject: [PATCH 040/170] feat(readarr): fetch Readarr book files and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 16 +++ src/cli/readarr/list_command_handler_tests.rs | 60 ++++++++ .../readarr_network/library/books/mod.rs | 32 ++++- .../books/readarr_books_network_tests.rs | 132 +++++++++++++++++- src/network/readarr_network/mod.rs | 6 + .../readarr_network_test_utils.rs | 32 ++++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 274 insertions(+), 5 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index fce642af..a488346d 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -37,6 +37,15 @@ pub enum ReadarrListCommand { )] book_id: i64, }, + #[command(about = "List all book files for the book with the given ID")] + BookFiles { + #[arg( + long, + help = "The Readarr ID of the book whose book files you want to list", + required = true + )] + book_id: i64, + }, #[command(about = "List all books for the author with the given ID")] Books { #[arg( @@ -122,6 +131,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::BookFiles { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookFiles(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Books { author_id } => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index ce74d970..40920bb8 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -130,6 +130,40 @@ mod tests { assert_eq!(book_editions_command, expected_args); } + #[test] + fn test_list_book_files_requires_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "book-files"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_book_files_success() { + let expected_args = ReadarrListCommand::BookFiles { book_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "book-files", + "--book-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(book_files_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(book_files_command, expected_args); + } + #[test] fn test_list_books_success() { let expected_args = ReadarrListCommand::Books { author_id: 1 }; @@ -281,6 +315,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_book_files_command() { + let expected_book_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookFiles(expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_book_files_command = ReadarrListCommand::BookFiles { book_id: 1 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_book_files_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_disk_space_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index df9b060d..6eda5412 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -1,7 +1,7 @@ use anyhow::Result; use log::info; -use crate::models::readarr_models::{Book, Edition}; +use crate::models::readarr_models::{Book, BookFile, Edition}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; @@ -63,6 +63,36 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_book_files( + &mut self, + book_id: i64, + ) -> Result> { + info!("Fetching book files for Readarr book with ID: {book_id}"); + let event = ReadarrEvent::GetBookFiles(book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("bookId={book_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |book_files_vec, mut app| { + let book_details_modal = app + .data + .readarr_data + .book_details_modal + .get_or_insert_default(); + + book_details_modal.book_files.set_items(book_files_vec); + }) + .await + } + pub(in crate::network::readarr_network) async fn get_books( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index ac347709..8bbc9740 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -1,11 +1,11 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::{Book, Edition, ReadarrSerdeable}; + use crate::models::readarr_models::{Book, BookFile, Edition, ReadarrSerdeable}; use crate::models::servarr_data::readarr::modals::BookDetailsModal; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - BOOK_JSON, EDITION_JSON, stale_book, stale_edition, + BOOK_FILE_JSON, BOOK_JSON, EDITION_JSON, stale_book, stale_book_file, stale_edition, }; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -241,6 +241,134 @@ mod tests { ); } + #[tokio::test] + async fn test_handle_get_book_files_event() { + let expected_book_files: Vec = vec![serde_json::from_str(BOOK_FILE_JSON).unwrap()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([ + serde_json::from_str::(BOOK_FILE_JSON).unwrap() + ])) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookFiles(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookFiles(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BookFiles(book_files) = result.unwrap() else { + panic!("Expected BookFiles") + }; + + assert_eq!(book_files, expected_book_files); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_files + .items, + expected_book_files + ); + } + + #[tokio::test] + async fn test_handle_get_book_files_event_empty_book_details_modal() { + let expected_book_files: Vec = vec![serde_json::from_str(BOOK_FILE_JSON).unwrap()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([ + serde_json::from_str::(BOOK_FILE_JSON).unwrap() + ])) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookFiles(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookFiles(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BookFiles(book_files) = result.unwrap() else { + panic!("Expected BookFiles") + }; + + assert_eq!(book_files, expected_book_files); + + let app = app.lock().await; + + assert_some!(&app.data.readarr_data.book_details_modal); + assert_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_files + .items, + expected_book_files + ); + } + + #[tokio::test] + async fn test_handle_get_book_files_event_failure() { + let stale_book_files = vec![stale_book_file()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([ + serde_json::from_str::(BOOK_FILE_JSON).unwrap() + ])) + .status(500) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookFiles(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_files + .set_items(stale_book_files.clone()); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookFiles(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_files + .items, + stale_book_files + ); + } + #[tokio::test] async fn test_handle_get_books_event() { let books_json = json!([ diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index e881bbc6..aefba954 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -35,6 +35,7 @@ pub enum ReadarrEvent { GetAuthorHistory(i64), GetBookDetails(i64), GetBookEditions(i64), + GetBookFiles(i64), GetBooks(i64), GetDiskSpace, GetHostConfig, @@ -69,6 +70,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", ReadarrEvent::GetBookDetails(_) | ReadarrEvent::GetBooks(_) => "/book", + ReadarrEvent::GetBookFiles(_) => "/bookfile", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) | ReadarrEvent::TriggerAutomaticAuthorSearch(_) @@ -143,6 +145,10 @@ impl Network<'_, '_> { .get_book_editions(book_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookFiles(book_id) => self + .get_book_files(book_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetBooks(author_id) => { self.get_books(author_id).await.map(ReadarrSerdeable::from) } diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index ebbc299c..3b4a2335 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -2,8 +2,8 @@ #[allow(dead_code)] pub mod test_utils { use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, Edition, - MonitorType, NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, BookFile, + Edition, MonitorType, NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, }; use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; @@ -67,6 +67,24 @@ pub mod test_utils { "grabbed": false }"#; + pub const BOOK_FILE_JSON: &str = r#"{ + "id": 1, + "authorId": 1, + "bookId": 1, + "path": "/nfs/books/Test Author/Test Book/Test Author - Test Book.azw3", + "size": 1074732, + "dateAdded": "2023-01-01T00:00:00Z", + "quality": { "quality": { "name": "AZW3" } }, + "mediaInfo": { + "audioBitRate": "128 kbps", + "audioChannels": 2, + "audioCodec": "MP3", + "audioBits": "16", + "audioSampleRate": "44.1 kHz" + }, + "qualityCutoffNotMet": false + }"#; + pub const EDITION_JSON: &str = r#"{ "id": 1, "bookId": 1, @@ -114,6 +132,16 @@ pub mod test_utils { } } + pub fn stale_book_file() -> BookFile { + BookFile { + id: 99, + author_id: 99, + book_id: 99, + path: "/nfs/books/Stale Author/Stale Book.azw3".to_owned(), + ..BookFile::default() + } + } + pub fn stale_edition() -> Edition { Edition { id: 99, diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index ea69ec3e..a1a33001 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -24,6 +24,7 @@ mod tests { #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] #[case(ReadarrEvent::GetBookDetails(1), "/book")] #[case(ReadarrEvent::GetBooks(1), "/book")] + #[case(ReadarrEvent::GetBookFiles(1), "/bookfile")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")] From b48ce5db2ed3de8d3ff734bb2ea81df2999cd17c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 15:40:08 -0600 Subject: [PATCH 041/170] feat(readarr): fetch Readarr book history and expose it on the CLI --- src/cli/readarr/list_command_handler.rs | 22 ++ src/cli/readarr/list_command_handler_tests.rs | 69 ++++ .../readarr_network/library/books/mod.rs | 44 ++- .../books/readarr_books_network_tests.rs | 296 +++++++++++++++++- src/network/readarr_network/mod.rs | 7 +- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 435 insertions(+), 4 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index a488346d..28399b04 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -46,6 +46,21 @@ pub enum ReadarrListCommand { )] book_id: i64, }, + #[command(about = "Fetch all history events for the book with the given ID")] + BookHistory { + #[arg( + long, + help = "The Readarr ID of the author who wrote the book whose history you wish to fetch", + required = true + )] + author_id: i64, + #[arg( + long, + help = "The Readarr ID of the book whose history you wish to fetch", + required = true + )] + book_id: i64, + }, #[command(about = "List all books for the author with the given ID")] Books { #[arg( @@ -138,6 +153,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::BookHistory { author_id, book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookHistory(author_id, book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Books { author_id } => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 40920bb8..8ae15276 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -164,6 +164,45 @@ mod tests { assert_eq!(book_files_command, expected_args); } + #[test] + fn test_list_book_history_requires_author_id_and_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "book-history"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_book_history_success() { + let expected_args = ReadarrListCommand::BookHistory { + author_id: 1, + book_id: 2, + }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "book-history", + "--author-id", + "1", + "--book-id", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(book_history_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(book_history_command, expected_args); + } + #[test] fn test_list_books_success() { let expected_args = ReadarrListCommand::Books { author_id: 1 }; @@ -341,6 +380,36 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_book_history_command() { + let expected_author_id = 1; + let expected_book_id = 2; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookHistory(expected_author_id, expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_book_history_command = ReadarrListCommand::BookHistory { + author_id: 1, + book_id: 2, + }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_book_history_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_disk_space_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index 6eda5412..46ab67aa 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -1,7 +1,9 @@ use anyhow::Result; use log::info; -use crate::models::readarr_models::{Book, BookFile, Edition}; +use crate::models::Route; +use crate::models::readarr_models::{Book, BookFile, Edition, ReadarrHistoryItem}; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; @@ -117,4 +119,44 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn get_readarr_book_history( + &mut self, + author_id: i64, + book_id: i64, + ) -> Result> { + info!("Fetching Readarr book history for book with ID: {book_id}"); + let event = ReadarrEvent::GetBookHistory(author_id, book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("authorId={author_id}&bookId={book_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut history_vec, mut app| { + let is_sorting = matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::BookHistorySortPrompt, _) + ); + + let book_details_modal = app + .data + .readarr_data + .book_details_modal + .get_or_insert_default(); + + if !is_sorting { + history_vec.sort_by_key(|item| item.id); + book_details_modal.book_history.set_items(history_vec); + book_details_modal.book_history.apply_sorting_toggle(false); + } + }) + .await + } } diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index 8bbc9740..8dd61c00 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -1,13 +1,19 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::{Book, BookFile, Edition, ReadarrSerdeable}; + use crate::models::readarr_models::{ + Book, BookFile, Edition, ReadarrHistoryItem, ReadarrSerdeable, + }; use crate::models::servarr_data::readarr::modals::BookDetailsModal; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::SortOption; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - BOOK_FILE_JSON, BOOK_JSON, EDITION_JSON, stale_book, stale_book_file, stale_edition, + BOOK_FILE_JSON, BOOK_JSON, EDITION_JSON, readarr_history_item, stale_book, stale_book_file, + stale_edition, stale_readarr_history_item, }; use pretty_assertions::assert_eq; + use rstest::rstest; use serde_json::{Value, json}; #[tokio::test] @@ -477,4 +483,290 @@ mod tests { assert_err!(result); assert_eq!(app.lock().await.data.readarr_data.books.items, stale_books); } + + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_book_history_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let history_json = json!([{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }, + ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1&bookId=2") + .build_for(ReadarrEvent::GetBookHistory(1, 2)) + .await; + app.lock().await.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + if use_custom_sorting { + let cmp_fn = |a: &ReadarrHistoryItem, b: &ReadarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .book_history + .sorting(vec![history_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .book_history + .sort_asc = true; + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookHistory(1, 2)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, response); + let app = app.lock().await; + let book_details_modal = app.data.readarr_data.book_details_modal.as_ref().unwrap(); + assert_eq!( + book_details_modal.book_history.items, + expected_history_items + ); + assert!(book_details_modal.book_history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_book_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!([{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1&bookId=2") + .build_for(ReadarrEvent::GetBookHistory(1, 2)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal.book_history.sort_asc = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal); + app.push_navigation_stack(ActiveReadarrBlock::BookHistorySortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookHistory(1, 2)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, response); + let app = app.lock().await; + let book_details_modal = app.data.readarr_data.book_details_modal.as_ref().unwrap(); + assert_is_empty!(book_details_modal.book_history); + assert!(book_details_modal.book_history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_book_history_event_empty_book_details_modal() { + let history_json = json!([{ + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let expected_history_items = vec![ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1&bookId=2") + .build_for(ReadarrEvent::GetBookHistory(1, 2)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookHistory(1, 2)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, expected_history_items); + + let app = app.lock().await; + + assert_some!(&app.data.readarr_data.book_details_modal); + assert_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .items, + expected_history_items + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_book_history_event_failure() { + let history_json = json!([{ + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .status(500) + .query("authorId=1&bookId=2") + .build_for(ReadarrEvent::GetBookHistory(1, 2)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_history + .set_items(stale_history_items.clone()); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookHistory(1, 2)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .items, + stale_history_items + ); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index aefba954..01209638 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -36,6 +36,7 @@ pub enum ReadarrEvent { GetBookDetails(i64), GetBookEditions(i64), GetBookFiles(i64), + GetBookHistory(i64, i64), GetBooks(i64), GetDiskSpace, GetHostConfig, @@ -80,7 +81,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::GetBookEditions(_) => "/edition", ReadarrEvent::HealthCheck => "/health", - ReadarrEvent::GetAuthorHistory(_) => "/history/author", + ReadarrEvent::GetAuthorHistory(_) | ReadarrEvent::GetBookHistory(_, _) => "/history/author", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -149,6 +150,10 @@ impl Network<'_, '_> { .get_book_files(book_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookHistory(author_id, book_id) => self + .get_readarr_book_history(author_id, book_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetBooks(author_id) => { self.get_books(author_id).await.map(ReadarrSerdeable::from) } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index a1a33001..aafda302 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -36,6 +36,7 @@ mod tests { #[case(ReadarrEvent::GetBookEditions(1), "/edition")] #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] + #[case(ReadarrEvent::GetBookHistory(1, 2), "/history/author")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] From f2787560d60169c6cb0a519c753110b9652e8080 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 15:52:44 -0600 Subject: [PATCH 042/170] feat(readarr): trigger an automatic book search and expose the command on the CLI --- ...rigger_automatic_search_command_handler.rs | 16 +++++ ..._automatic_search_command_handler_tests.rs | 68 +++++++++++++++++++ .../readarr_network/library/books/mod.rs | 26 ++++++- .../books/readarr_books_network_tests.rs | 43 ++++++++++++ src/network/readarr_network/mod.rs | 6 ++ .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/cli/readarr/trigger_automatic_search_command_handler.rs b/src/cli/readarr/trigger_automatic_search_command_handler.rs index 4722bc6a..16f2daa0 100644 --- a/src/cli/readarr/trigger_automatic_search_command_handler.rs +++ b/src/cli/readarr/trigger_automatic_search_command_handler.rs @@ -27,6 +27,15 @@ pub enum ReadarrTriggerAutomaticSearchCommand { )] author_id: i64, }, + #[command(about = "Trigger an automatic search for the book with the specified ID")] + Book { + #[arg( + long, + help = "The ID of the book you want to trigger an automatic search for", + required = true + )] + book_id: i64, + }, } impl From for Command { @@ -65,6 +74,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrTriggerAutomaticSearchCommand> .await?; serde_json::to_string_pretty(&resp)? } + ReadarrTriggerAutomaticSearchCommand::Book { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::TriggerAutomaticBookSearch(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs b/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs index d678fa52..bf82fe50 100644 --- a/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs +++ b/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs @@ -68,6 +68,45 @@ mod tests { }; assert_eq!(trigger_automatic_search_command, expected_args); } + + #[test] + fn test_trigger_automatic_book_search_requires_book_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "book", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_book_search_with_book_id() { + let expected_args = ReadarrTriggerAutomaticSearchCommand::Book { book_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "book", + "--book-id", + "1", + ]); + + assert_ok!(&result); + let Some(Command::Readarr(ReadarrCommand::TriggerAutomaticSearch( + trigger_automatic_search_command, + ))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(trigger_automatic_search_command, expected_args); + } } mod handler { @@ -114,5 +153,34 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_handle_trigger_automatic_book_search_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::TriggerAutomaticBookSearch(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let trigger_automatic_search_command = + ReadarrTriggerAutomaticSearchCommand::Book { book_id: 1 }; + + let result = ReadarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index 46ab67aa..cf696042 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -1,8 +1,11 @@ use anyhow::Result; use log::info; +use serde_json::Value; use crate::models::Route; -use crate::models::readarr_models::{Book, BookFile, Edition, ReadarrHistoryItem}; +use crate::models::readarr_models::{ + Book, BookFile, Edition, ReadarrCommandBody, ReadarrHistoryItem, +}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; @@ -159,4 +162,25 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn trigger_automatic_book_search( + &mut self, + book_id: i64, + ) -> Result { + let event = ReadarrEvent::TriggerAutomaticBookSearch(book_id); + info!("Searching indexers for book with ID: {book_id}"); + let body = ReadarrCommandBody { + name: "BookSearch".to_owned(), + book_ids: Some(vec![book_id]), + ..ReadarrCommandBody::default() + }; + + 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/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index 8dd61c00..7ad77453 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -769,4 +769,47 @@ mod tests { stale_history_items ); } + + #[tokio::test] + async fn test_handle_trigger_automatic_book_search_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "BookSearch", + "bookIds": [1] + })) + .returns(json!({})) + .build_for(ReadarrEvent::TriggerAutomaticBookSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticBookSearch(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_book_search_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "BookSearch", + "bookIds": [1] + })) + .returns(json!({"name": "BookSearch", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::TriggerAutomaticBookSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticBookSearch(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 01209638..893fe95a 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -56,6 +56,7 @@ pub enum ReadarrEvent { StartTask(ReadarrTaskName), ToggleAuthorMonitoring(i64), TriggerAutomaticAuthorSearch(i64), + TriggerAutomaticBookSearch(i64), UpdateAllAuthors, UpdateAndScanAuthor(i64), } @@ -75,6 +76,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) | ReadarrEvent::TriggerAutomaticAuthorSearch(_) + | ReadarrEvent::TriggerAutomaticBookSearch(_) | ReadarrEvent::UpdateAllAuthors | ReadarrEvent::UpdateAndScanAuthor(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", @@ -213,6 +215,10 @@ impl Network<'_, '_> { .trigger_automatic_author_search(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::TriggerAutomaticBookSearch(book_id) => self + .trigger_automatic_book_search(book_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::UpdateAllAuthors => self.update_all_authors().await.map(ReadarrSerdeable::from), ReadarrEvent::UpdateAndScanAuthor(author_id) => self .update_and_scan_author(author_id) diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index aafda302..64155d60 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -28,6 +28,7 @@ mod tests { #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")] + #[case(ReadarrEvent::TriggerAutomaticBookSearch(1), "/command")] #[case(ReadarrEvent::UpdateAllAuthors, "/command")] #[case(ReadarrEvent::UpdateAndScanAuthor(1), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] From e30d4dbd0c86cf3852ff61d7c28150d68cb5fbfb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 16:12:50 -0600 Subject: [PATCH 043/170] feat(readarr): toggle book monitoring and expose the command on the CLI The PUT body round-trips through serde_json::Value rather than the typed Book, so the fields Readarr returns but Book does not declare survive the toggle instead of being silently dropped. BOOK_JSON now carries a few of those undeclared fields so a regression to a typed round-trip fails the tests rather than passing unnoticed. --- src/cli/readarr/mod.rs | 16 ++++ src/cli/readarr/readarr_command_tests.rs | 50 ++++++++++++ .../readarr_network/library/books/mod.rs | 67 +++++++++++++++- .../books/readarr_books_network_tests.rs | 79 +++++++++++++++++++ src/network/readarr_network/mod.rs | 9 ++- .../readarr_network_test_utils.rs | 3 + .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 222 insertions(+), 3 deletions(-) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 737ebb4f..5363005e 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -97,6 +97,15 @@ pub enum ReadarrCommand { )] author_id: i64, }, + #[command(about = "Toggle monitoring for the specified book corresponding to the given book ID")] + ToggleBookMonitoring { + #[arg( + long, + help = "The Readarr ID of the book to toggle monitoring on", + required = true + )] + book_id: i64, + }, } impl From for Command { @@ -186,6 +195,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .await?; serde_json::to_string_pretty(&resp)? } + ReadarrCommand::ToggleBookMonitoring { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::ToggleBookMonitoring(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index e702c4db..9bcc7e4e 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -101,6 +101,31 @@ mod tests { assert_ok!(&result); } + + #[test] + fn test_toggle_book_monitoring_requires_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "toggle-book-monitoring"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_toggle_book_monitoring_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "toggle-book-monitoring", + "--book-id", + "1", + ]); + + assert_ok!(&result); + } } mod handler { @@ -203,5 +228,30 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_toggle_book_monitoring_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::ToggleBookMonitoring(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let toggle_book_monitoring_command = ReadarrCommand::ToggleBookMonitoring { book_id: 1 }; + + let result = + ReadarrCliHandler::with(&app_arc, toggle_book_monitoring_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index cf696042..2f4e9d56 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use log::info; -use serde_json::Value; +use log::{debug, info, warn}; +use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{ @@ -163,6 +163,69 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn toggle_book_monitoring( + &mut self, + book_id: i64, + ) -> Result<()> { + let event = ReadarrEvent::ToggleBookMonitoring(book_id); + + let detail_event = ReadarrEvent::GetBookDetails(book_id); + info!("Toggling book monitoring for book with ID: {book_id}"); + info!("Fetching book details for book with ID: {book_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{book_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_book_body, _| { + response = detailed_book_body.to_string() + }) + .await?; + + info!("Constructing toggle book monitoring body"); + + match serde_json::from_str::(&response) { + Ok(mut detailed_book_body) => { + let monitored = detailed_book_body + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + + *detailed_book_body.get_mut("monitored").unwrap() = json!(!monitored); + + debug!("Toggle book monitoring body: {detailed_book_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_book_body), + Some(format!("/{book_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + Err(_) => { + warn!("Request for detailed book body was interrupted"); + Ok(()) + } + } + } + pub(in crate::network::readarr_network) async fn trigger_automatic_book_search( &mut self, book_id: i64, diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index 7ad77453..d3b88315 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -6,12 +6,14 @@ mod tests { use crate::models::servarr_data::readarr::modals::BookDetailsModal; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::SortOption; + use crate::network::NetworkResource; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ BOOK_FILE_JSON, BOOK_JSON, EDITION_JSON, readarr_history_item, stale_book, stale_book_file, stale_edition, stale_readarr_history_item, }; + use mockito::Matcher; use pretty_assertions::assert_eq; use rstest::rstest; use serde_json::{Value, json}; @@ -770,6 +772,83 @@ mod tests { ); } + #[rstest] + #[case(true, false)] + #[case(false, true)] + #[tokio::test] + async fn test_handle_toggle_book_monitoring_event( + #[case] initial_monitored: bool, + #[case] expected_monitored: bool, + ) { + let mut book_json: Value = serde_json::from_str(BOOK_JSON).unwrap(); + *book_json.get_mut("monitored").unwrap() = json!(initial_monitored); + let mut expected_body = book_json.clone(); + *expected_body.get_mut("monitored").unwrap() = json!(expected_monitored); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(book_json) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleBookMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleBookMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_toggle_book_monitoring_event_failure() { + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(BOOK_JSON).unwrap()) + .status(404) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleBookMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleBookMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_trigger_automatic_book_search_event() { let (mock, app, _server) = MockServarrApi::post() diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 893fe95a..108a7e20 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -55,6 +55,7 @@ pub enum ReadarrEvent { SearchNewAuthor(String), StartTask(ReadarrTaskName), ToggleAuthorMonitoring(i64), + ToggleBookMonitoring(i64), TriggerAutomaticAuthorSearch(i64), TriggerAutomaticBookSearch(i64), UpdateAllAuthors, @@ -71,7 +72,9 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", - ReadarrEvent::GetBookDetails(_) | ReadarrEvent::GetBooks(_) => "/book", + ReadarrEvent::GetBookDetails(_) + | ReadarrEvent::GetBooks(_) + | ReadarrEvent::ToggleBookMonitoring(_) => "/book", ReadarrEvent::GetBookFiles(_) => "/bookfile", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) @@ -211,6 +214,10 @@ impl Network<'_, '_> { .toggle_author_monitoring(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::ToggleBookMonitoring(book_id) => self + .toggle_book_monitoring(book_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::TriggerAutomaticAuthorSearch(author_id) => self .trigger_automatic_author_search(author_id) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 3b4a2335..85e60f99 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -51,6 +51,8 @@ pub mod test_utils { pub const BOOK_JSON: &str = r#"{ "id": 1, "title": "Test Book", + "authorTitle": "Test Author Test Book", + "seriesTitle": "Test Series", "authorId": 1, "foreignBookId": "test-foreign-book-id", "monitored": true, @@ -64,6 +66,7 @@ pub mod test_utils { "sizeOnDisk": 12345, "percentOfBooks": 66.6 }, + "lastSearchTime": "2023-01-01T00:00:00Z", "grabbed": false }"#; diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 64155d60..a443d127 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -24,6 +24,7 @@ mod tests { #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] #[case(ReadarrEvent::GetBookDetails(1), "/book")] #[case(ReadarrEvent::GetBooks(1), "/book")] + #[case(ReadarrEvent::ToggleBookMonitoring(1), "/book")] #[case(ReadarrEvent::GetBookFiles(1), "/bookfile")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] From fbfd64eb2ab47a04534643f63f9a07322d7b3e7f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 16:19:49 -0600 Subject: [PATCH 044/170] feat(readarr): delete a book and expose the command on the CLI The delete flags travel as query parameters rather than a request body, and Readarr spells the exclusion key addImportListExclusion, not the addImportExclusion that Radarr uses. --- src/cli/readarr/delete_command_handler.rs | 25 ++++ .../readarr/delete_command_handler_tests.rs | 107 ++++++++++++++++++ .../readarr_network/library/books/mod.rs | 34 +++++- .../books/readarr_books_network_tests.rs | 56 ++++++++- src/network/readarr_network/mod.rs | 8 +- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 228 insertions(+), 3 deletions(-) diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs index 118a50c6..7667160c 100644 --- a/src/cli/readarr/delete_command_handler.rs +++ b/src/cli/readarr/delete_command_handler.rs @@ -27,6 +27,15 @@ pub enum ReadarrDeleteCommand { #[arg(long, help = "Add a list exclusion for this author")] add_list_exclusion: bool, }, + #[command(about = "Delete a book from your Readarr library")] + Book { + #[arg(long, help = "The ID of the book to delete", required = true)] + book_id: i64, + #[arg(long, help = "Delete the book files from disk as well")] + delete_files_from_disk: bool, + #[arg(long, help = "Add a list exclusion for this book")] + add_list_exclusion: bool, + }, #[command(about = "Delete the root folder with the given ID")] RootFolder { #[arg(long, help = "The ID of the root folder to delete", required = true)] @@ -84,6 +93,22 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> .await?; serde_json::to_string_pretty(&resp)? } + ReadarrDeleteCommand::Book { + book_id, + delete_files_from_disk, + add_list_exclusion, + } => { + let delete_book_params = DeleteParams { + id: book_id, + delete_files: delete_files_from_disk, + add_import_list_exclusion: add_list_exclusion, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteBook(delete_book_params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrDeleteCommand::RootFolder { root_folder_id } => { let resp = self .network diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs index 4d4ee145..21488b2b 100644 --- a/src/cli/readarr/delete_command_handler_tests.rs +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -104,6 +104,79 @@ mod tests { assert_eq!(delete_command, expected_args); } + #[test] + fn test_delete_book_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "book"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_book_book_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "book", + "--book-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_book_defaults() { + let expected_args = ReadarrDeleteCommand::Book { + book_id: 1, + delete_files_from_disk: false, + add_list_exclusion: false, + }; + + let result = Cli::try_parse_from(["managarr", "readarr", "delete", "book", "--book-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_book_all_args_defined() { + let expected_args = ReadarrDeleteCommand::Book { + book_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "book", + "--book-id", + "1", + "--delete-files-from-disk", + "--add-list-exclusion", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + #[test] fn test_delete_root_folder_requires_arguments() { let result = @@ -248,6 +321,40 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_delete_book_command() { + let expected_delete_book_params = DeleteParams { + id: 1, + delete_files: true, + add_import_list_exclusion: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::DeleteBook(expected_delete_book_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_book_command = ReadarrDeleteCommand::Book { + book_id: 1, + delete_files_from_disk: true, + add_list_exclusion: false, + }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_book_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_delete_root_folder_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index 2f4e9d56..b4cb29e7 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{ - Book, BookFile, Edition, ReadarrCommandBody, ReadarrHistoryItem, + Book, BookFile, DeleteParams, Edition, ReadarrCommandBody, ReadarrHistoryItem, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::network::readarr_network::ReadarrEvent; @@ -15,6 +15,38 @@ use crate::network::{Network, RequestMethod}; mod readarr_books_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn delete_book( + &mut self, + delete_book_params: DeleteParams, + ) -> Result<()> { + let event = ReadarrEvent::DeleteBook(DeleteParams::default()); + let DeleteParams { + id, + delete_files, + add_import_list_exclusion, + } = delete_book_params; + + info!( + "Deleting Readarr book with ID: {id} with deleteFiles={delete_files} and addImportListExclusion={add_import_list_exclusion}" + ); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + Some(format!( + "deleteFiles={delete_files}&addImportListExclusion={add_import_list_exclusion}" + )), + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_book_details( &mut self, book_id: i64, diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index d3b88315..dfb4fc5c 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::{ - Book, BookFile, Edition, ReadarrHistoryItem, ReadarrSerdeable, + Book, BookFile, DeleteParams, Edition, ReadarrHistoryItem, ReadarrSerdeable, }; use crate::models::servarr_data::readarr::modals::BookDetailsModal; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; @@ -18,6 +18,60 @@ mod tests { use rstest::rstest; use serde_json::{Value, json}; + #[rstest] + #[case(true, false, "deleteFiles=true&addImportListExclusion=false")] + #[case(false, true, "deleteFiles=false&addImportListExclusion=true")] + #[tokio::test] + async fn test_handle_delete_book_event( + #[case] delete_files: bool, + #[case] add_import_list_exclusion: bool, + #[case] expected_query: &str, + ) { + let delete_book_params = DeleteParams { + id: 1, + delete_files, + add_import_list_exclusion, + }; + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .query(expected_query) + .build_for(ReadarrEvent::DeleteBook(delete_book_params.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBook(delete_book_params)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_book_event_failure() { + let delete_book_params = DeleteParams { + id: 1, + delete_files: true, + add_import_list_exclusion: false, + }; + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .query("deleteFiles=true&addImportListExclusion=false") + .status(500) + .build_for(ReadarrEvent::DeleteBook(delete_book_params.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBook(delete_book_params)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_book_details_event() { let expected_book: Book = serde_json::from_str(BOOK_JSON).unwrap(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 108a7e20..0a9f40f7 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -28,6 +28,7 @@ pub enum ReadarrEvent { AddRootFolder(AddReadarrRootFolderBody), AddTag(String), DeleteAuthor(DeleteParams), + DeleteBook(DeleteParams), DeleteRootFolder(i64), DeleteTag(i64), EditAuthor(EditAuthorParams), @@ -72,7 +73,8 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", - ReadarrEvent::GetBookDetails(_) + ReadarrEvent::DeleteBook(_) + | ReadarrEvent::GetBookDetails(_) | ReadarrEvent::GetBooks(_) | ReadarrEvent::ToggleBookMonitoring(_) => "/book", ReadarrEvent::GetBookFiles(_) => "/bookfile", @@ -123,6 +125,10 @@ impl Network<'_, '_> { .delete_author(delete_author_params) .await .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteBook(delete_book_params) => self + .delete_book(delete_book_params) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::DeleteRootFolder(root_folder_id) => self .delete_readarr_root_folder(root_folder_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index a443d127..c0e205d8 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -22,6 +22,7 @@ mod tests { #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] + #[case(ReadarrEvent::DeleteBook(DeleteParams::default()), "/book")] #[case(ReadarrEvent::GetBookDetails(1), "/book")] #[case(ReadarrEvent::GetBooks(1), "/book")] #[case(ReadarrEvent::ToggleBookMonitoring(1), "/book")] From 3569bcc036517ed3799e271b1a0f3eee5655318f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 12:58:41 -0600 Subject: [PATCH 045/170] feat(readarr): delete a book file and expose the command on the CLI --- src/cli/readarr/delete_command_handler.rs | 12 ++++ .../readarr/delete_command_handler_tests.rs | 72 +++++++++++++++++++ .../readarr_network/library/books/mod.rs | 22 ++++++ .../books/readarr_books_network_tests.rs | 35 +++++++++ src/network/readarr_network/mod.rs | 7 +- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs index 7667160c..7e69bcfb 100644 --- a/src/cli/readarr/delete_command_handler.rs +++ b/src/cli/readarr/delete_command_handler.rs @@ -36,6 +36,11 @@ pub enum ReadarrDeleteCommand { #[arg(long, help = "Add a list exclusion for this book")] add_list_exclusion: bool, }, + #[command(about = "Delete the specified book file from disk")] + BookFile { + #[arg(long, help = "The ID of the book file to delete", required = true)] + book_file_id: i64, + }, #[command(about = "Delete the root folder with the given ID")] RootFolder { #[arg(long, help = "The ID of the root folder to delete", required = true)] @@ -109,6 +114,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> .await?; serde_json::to_string_pretty(&resp)? } + ReadarrDeleteCommand::BookFile { book_file_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteBookFile(book_file_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrDeleteCommand::RootFolder { root_folder_id } => { let resp = self .network diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs index 21488b2b..ae2cea42 100644 --- a/src/cli/readarr/delete_command_handler_tests.rs +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -177,6 +177,55 @@ mod tests { assert_eq!(delete_command, expected_args); } + #[test] + fn test_delete_book_file_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "book-file"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_book_file_book_file_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "book-file", + "--book-file-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_book_file_success() { + let expected_args = ReadarrDeleteCommand::BookFile { book_file_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "book-file", + "--book-file-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + #[test] fn test_delete_root_folder_requires_arguments() { let result = @@ -355,6 +404,29 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_delete_book_file_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteBookFile(1).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_book_file_command = ReadarrDeleteCommand::BookFile { book_file_id: 1 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_book_file_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_delete_root_folder_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index b4cb29e7..b5979946 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -47,6 +47,28 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn delete_readarr_book_file( + &mut self, + book_file_id: i64, + ) -> Result<()> { + info!("Deleting Readarr book file with ID: {book_file_id}"); + let event = ReadarrEvent::DeleteBookFile(book_file_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{book_file_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_book_details( &mut self, book_id: i64, diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index dfb4fc5c..ed18d2b7 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -72,6 +72,41 @@ mod tests { assert_err!(result); } + #[tokio::test] + async fn test_handle_delete_readarr_book_file_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .build_for(ReadarrEvent::DeleteBookFile(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBookFile(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_book_file_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .status(500) + .build_for(ReadarrEvent::DeleteBookFile(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBookFile(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_book_details_event() { let expected_book: Book = serde_json::from_str(BOOK_JSON).unwrap(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 0a9f40f7..544c12d3 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -29,6 +29,7 @@ pub enum ReadarrEvent { AddTag(String), DeleteAuthor(DeleteParams), DeleteBook(DeleteParams), + DeleteBookFile(i64), DeleteRootFolder(i64), DeleteTag(i64), EditAuthor(EditAuthorParams), @@ -77,7 +78,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::GetBookDetails(_) | ReadarrEvent::GetBooks(_) | ReadarrEvent::ToggleBookMonitoring(_) => "/book", - ReadarrEvent::GetBookFiles(_) => "/bookfile", + ReadarrEvent::DeleteBookFile(_) | ReadarrEvent::GetBookFiles(_) => "/bookfile", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) | ReadarrEvent::TriggerAutomaticAuthorSearch(_) @@ -129,6 +130,10 @@ impl Network<'_, '_> { .delete_book(delete_book_params) .await .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteBookFile(book_file_id) => self + .delete_readarr_book_file(book_file_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::DeleteRootFolder(root_folder_id) => self .delete_readarr_root_folder(root_folder_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index c0e205d8..5b4a9bf4 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -26,6 +26,7 @@ mod tests { #[case(ReadarrEvent::GetBookDetails(1), "/book")] #[case(ReadarrEvent::GetBooks(1), "/book")] #[case(ReadarrEvent::ToggleBookMonitoring(1), "/book")] + #[case(ReadarrEvent::DeleteBookFile(1), "/bookfile")] #[case(ReadarrEvent::GetBookFiles(1), "/bookfile")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] From 6139b48d904b497f8ae1e76c5af00c4dd4f01a1e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 13:14:07 -0600 Subject: [PATCH 046/170] feat(readarr): fetch Readarr downloads and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 12 +++ src/cli/readarr/list_command_handler_tests.rs | 75 +++++++++++++++++ src/network/readarr_network/downloads/mod.rs | 40 +++++++++ .../readarr_downloads_network_tests.rs | 84 +++++++++++++++++++ src/network/readarr_network/mod.rs | 7 ++ .../readarr_network_test_utils.rs | 48 ++++++++++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 src/network/readarr_network/downloads/mod.rs create mode 100644 src/network/readarr_network/downloads/readarr_downloads_network_tests.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 28399b04..5782e6a5 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -72,6 +72,11 @@ pub enum ReadarrListCommand { }, #[command(about = "List disk space details for all provisioned root folders in Readarr")] DiskSpace, + #[command(about = "List all active downloads in Readarr")] + Downloads { + #[arg(long, help = "How many downloads to fetch", default_value_t = 500)] + count: u64, + }, #[command(about = "Fetch Readarr logs")] Logs { #[arg(long, help = "How many log events to fetch", default_value_t = 500)] @@ -174,6 +179,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Downloads { count } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetDownloads(count).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Logs { events, output_in_log_format, diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 8ae15276..c230e6b6 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -52,6 +52,20 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); } + #[test] + fn test_list_downloads_count_flag_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "list", + "downloads", + "--count", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + #[test] fn test_list_author_history_requires_author_id() { let result = @@ -234,6 +248,41 @@ mod tests { }; assert_eq!(logs_command, expected_args); } + + #[test] + fn test_list_downloads_default_values() { + let expected_args = ReadarrListCommand::Downloads { count: 500 }; + let result = Cli::try_parse_from(["managarr", "readarr", "list", "downloads"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(downloads_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(downloads_command, expected_args); + } + + #[test] + fn test_list_downloads_success() { + let expected_args = ReadarrListCommand::Downloads { count: 1000 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "downloads", + "--count", + "1000", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(downloads_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(downloads_command, expected_args); + } } mod handler { @@ -433,6 +482,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_downloads_command() { + let expected_count = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetDownloads(expected_count).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_downloads_command = ReadarrListCommand::Downloads { count: 1000 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_downloads_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_logs_command() { let expected_events = 1000; diff --git a/src/network/readarr_network/downloads/mod.rs b/src/network/readarr_network/downloads/mod.rs new file mode 100644 index 00000000..2613c491 --- /dev/null +++ b/src/network/readarr_network/downloads/mod.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use log::info; + +use crate::models::readarr_models::DownloadsResponse; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_downloads_network_tests.rs"] +mod readarr_downloads_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_downloads( + &mut self, + count: u64, + ) -> Result { + info!("Fetching Readarr downloads"); + let event = ReadarrEvent::GetDownloads(count); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("pageSize={count}")), + ) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .readarr_data + .downloads + .set_items(queue_response.records); + }) + .await + } +} diff --git a/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs b/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs new file mode 100644 index 00000000..303ccddd --- /dev/null +++ b/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs @@ -0,0 +1,84 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::ReadarrSerdeable; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + DOWNLOAD_RECORD_JSON, download_record, downloads_response, stale_download_record, + }; + use pretty_assertions::assert_eq; + use serde_json::{Value, json}; + + #[tokio::test] + async fn test_handle_get_downloads_event() { + let downloads_json = json!({ + "records": [serde_json::from_str::(DOWNLOAD_RECORD_JSON).unwrap()] + }); + let (mock, app, _server) = MockServarrApi::get() + .returns(downloads_json) + .query("pageSize=250") + .build_for(ReadarrEvent::GetDownloads(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .downloads + .set_items(vec![stale_download_record()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetDownloads(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::DownloadsResponse(downloads) = result.unwrap() else { + panic!("Expected DownloadsResponse") + }; + + assert_eq!(downloads, downloads_response()); + assert_eq!( + app.lock().await.data.readarr_data.downloads.items, + vec![download_record()] + ); + } + + #[tokio::test] + async fn test_handle_get_downloads_event_failure() { + let stale_downloads = vec![stale_download_record()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({ + "records": [serde_json::from_str::(DOWNLOAD_RECORD_JSON).unwrap()] + })) + .status(500) + .query("pageSize=500") + .build_for(ReadarrEvent::GetDownloads(500)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .downloads + .set_items(stale_downloads.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetDownloads(500)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.downloads.items, + stale_downloads + ); + } +} diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 544c12d3..4bba1c86 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -10,6 +10,7 @@ use crate::models::readarr_models::{ use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; +mod downloads; mod library; mod root_folders; mod system; @@ -41,6 +42,7 @@ pub enum ReadarrEvent { GetBookHistory(i64, i64), GetBooks(i64), GetDiskSpace, + GetDownloads(u64), GetHostConfig, GetLogs(u64), GetMetadataProfiles, @@ -93,6 +95,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", + ReadarrEvent::GetDownloads(_) => "/queue", ReadarrEvent::AddRootFolder(_) | ReadarrEvent::DeleteRootFolder(_) | ReadarrEvent::GetRootFolders => "/rootfolder", @@ -177,6 +180,10 @@ impl Network<'_, '_> { .get_readarr_diskspace() .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetDownloads(count) => self + .get_readarr_downloads(count) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetHostConfig => self .get_readarr_host_config() .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 85e60f99..c047d5fd 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -3,11 +3,12 @@ pub mod test_utils { use crate::models::readarr_models::{ AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, BookFile, - Edition, MonitorType, NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, - ReadarrHistoryItem, + DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType, NewItemMonitorType, + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, }; use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; use chrono::DateTime; + use serde_json::Number; pub const AUTHOR_JSON: &str = r#"{ "id": 1, @@ -106,6 +107,19 @@ pub mod test_utils { "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } }"#; + pub const DOWNLOAD_RECORD_JSON: &str = r#"{ + "title": "Test Book Download", + "status": "downloading", + "id": 1, + "bookId": 1, + "authorId": 1, + "size": 1000.0, + "sizeleft": 250.0, + "outputPath": "/nfs/nzbget/completed/books/Test Author - Test Book", + "indexer": "test-indexer", + "downloadClient": "NZBGet" + }"#; + pub fn stale_author() -> Author { Author { id: 99, @@ -184,6 +198,15 @@ pub mod test_utils { } } + pub fn stale_download_record() -> DownloadRecord { + DownloadRecord { + id: 99, + title: "Stale Download".to_owned(), + status: DownloadStatus::Queued, + ..DownloadRecord::default() + } + } + pub fn quality_wrapper() -> QualityWrapper { QualityWrapper { quality: quality() } } @@ -232,4 +255,25 @@ pub mod test_utils { }, } } + + pub fn download_record() -> DownloadRecord { + DownloadRecord { + title: "Test Book Download".to_owned(), + status: DownloadStatus::Downloading, + id: 1, + book_id: Some(Number::from(1)), + author_id: Some(Number::from(1)), + size: 1000.0, + sizeleft: 250.0, + output_path: Some("/nfs/nzbget/completed/books/Test Author - Test Book".into()), + indexer: "test-indexer".to_owned(), + download_client: Some("NZBGet".to_owned()), + } + } + + pub fn downloads_response() -> DownloadsResponse { + DownloadsResponse { + records: vec![download_record()], + } + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 5b4a9bf4..9d4fc8c0 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -44,6 +44,7 @@ mod tests { #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(ReadarrEvent::GetDownloads(500), "/queue")] #[case( ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()), "/rootfolder" From 5c9fead72587fdcc0cbac998879ee4e41aeeabf2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 13:18:53 -0600 Subject: [PATCH 047/170] feat(readarr): delete a download and expose the command on the CLI --- src/cli/readarr/delete_command_handler.rs | 12 ++++ .../readarr/delete_command_handler_tests.rs | 72 +++++++++++++++++++ src/network/readarr_network/downloads/mod.rs | 22 ++++++ .../readarr_downloads_network_tests.rs | 35 +++++++++ src/network/readarr_network/mod.rs | 7 +- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs index 7e69bcfb..35ef2188 100644 --- a/src/cli/readarr/delete_command_handler.rs +++ b/src/cli/readarr/delete_command_handler.rs @@ -41,6 +41,11 @@ pub enum ReadarrDeleteCommand { #[arg(long, help = "The ID of the book file to delete", required = true)] book_file_id: i64, }, + #[command(about = "Delete the specified download")] + Download { + #[arg(long, help = "The ID of the download to delete", required = true)] + download_id: i64, + }, #[command(about = "Delete the root folder with the given ID")] RootFolder { #[arg(long, help = "The ID of the root folder to delete", required = true)] @@ -121,6 +126,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> .await?; serde_json::to_string_pretty(&resp)? } + ReadarrDeleteCommand::Download { download_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteDownload(download_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrDeleteCommand::RootFolder { root_folder_id } => { let resp = self .network diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs index ae2cea42..3b763ee9 100644 --- a/src/cli/readarr/delete_command_handler_tests.rs +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -226,6 +226,55 @@ mod tests { assert_eq!(delete_command, expected_args); } + #[test] + fn test_delete_download_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "download"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_download_download_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "download", + "--download-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_download_success() { + let expected_args = ReadarrDeleteCommand::Download { download_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "download", + "--download-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + #[test] fn test_delete_root_folder_requires_arguments() { let result = @@ -427,6 +476,29 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_delete_download_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteDownload(1).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_download_command = ReadarrDeleteCommand::Download { download_id: 1 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_download_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_delete_root_folder_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/downloads/mod.rs b/src/network/readarr_network/downloads/mod.rs index 2613c491..3ec87086 100644 --- a/src/network/readarr_network/downloads/mod.rs +++ b/src/network/readarr_network/downloads/mod.rs @@ -10,6 +10,28 @@ use crate::network::{Network, RequestMethod}; mod readarr_downloads_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn delete_readarr_download( + &mut self, + download_id: i64, + ) -> Result<()> { + let event = ReadarrEvent::DeleteDownload(download_id); + info!("Deleting Readarr download for download with id: {download_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{download_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_downloads( &mut self, count: u64, diff --git a/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs b/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs index 303ccddd..8d90ce7a 100644 --- a/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs +++ b/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs @@ -9,6 +9,41 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::{Value, json}; + #[tokio::test] + async fn test_handle_delete_readarr_download_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/7") + .build_for(ReadarrEvent::DeleteDownload(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteDownload(7)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_download_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/7") + .status(500) + .build_for(ReadarrEvent::DeleteDownload(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteDownload(7)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_downloads_event() { let downloads_json = json!({ diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 4bba1c86..39062509 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -31,6 +31,7 @@ pub enum ReadarrEvent { DeleteAuthor(DeleteParams), DeleteBook(DeleteParams), DeleteBookFile(i64), + DeleteDownload(i64), DeleteRootFolder(i64), DeleteTag(i64), EditAuthor(EditAuthorParams), @@ -95,7 +96,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", - ReadarrEvent::GetDownloads(_) => "/queue", + ReadarrEvent::DeleteDownload(_) | ReadarrEvent::GetDownloads(_) => "/queue", ReadarrEvent::AddRootFolder(_) | ReadarrEvent::DeleteRootFolder(_) | ReadarrEvent::GetRootFolders => "/rootfolder", @@ -137,6 +138,10 @@ impl Network<'_, '_> { .delete_readarr_book_file(book_file_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteDownload(download_id) => self + .delete_readarr_download(download_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::DeleteRootFolder(root_folder_id) => self .delete_readarr_root_folder(root_folder_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 9d4fc8c0..7bbfde94 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -44,6 +44,7 @@ mod tests { #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(ReadarrEvent::DeleteDownload(1), "/queue")] #[case(ReadarrEvent::GetDownloads(500), "/queue")] #[case( ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()), From f5bcb7d83a5672543a2a9a372792ba2547a3da44 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 13:23:26 -0600 Subject: [PATCH 048/170] feat(readarr): refresh Readarr downloads and expose the command on the CLI --- src/cli/readarr/refresh_command_handler.rs | 9 ++++ .../readarr/refresh_command_handler_tests.rs | 30 +++++++++++++ src/network/readarr_network/downloads/mod.rs | 20 +++++++++ .../readarr_downloads_network_tests.rs | 45 +++++++++++++++++++ src/network/readarr_network/mod.rs | 8 +++- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/cli/readarr/refresh_command_handler.rs b/src/cli/readarr/refresh_command_handler.rs index fe186b2a..5112e7d9 100644 --- a/src/cli/readarr/refresh_command_handler.rs +++ b/src/cli/readarr/refresh_command_handler.rs @@ -29,6 +29,8 @@ pub enum ReadarrRefreshCommand { )] author_id: i64, }, + #[command(about = "Refresh all downloads in Readarr")] + Downloads, } impl From for Command { @@ -74,6 +76,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrRefreshCommand> .await?; serde_json::to_string_pretty(&resp)? } + ReadarrRefreshCommand::Downloads => { + let resp = self + .network + .handle_network_event(ReadarrEvent::UpdateDownloads.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/readarr/refresh_command_handler_tests.rs b/src/cli/readarr/refresh_command_handler_tests.rs index b5e3182e..14309988 100644 --- a/src/cli/readarr/refresh_command_handler_tests.rs +++ b/src/cli/readarr/refresh_command_handler_tests.rs @@ -32,6 +32,14 @@ mod tests { assert_ok!(&result); } + #[test] + fn test_refresh_downloads_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "refresh", "downloads"]); + + assert_ok!(&result); + } + #[test] fn test_refresh_author_requires_author_id() { let result = @@ -128,5 +136,27 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_handle_refresh_downloads_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::UpdateDownloads.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_command = ReadarrRefreshCommand::Downloads; + + let result = ReadarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/downloads/mod.rs b/src/network/readarr_network/downloads/mod.rs index 3ec87086..43f1a427 100644 --- a/src/network/readarr_network/downloads/mod.rs +++ b/src/network/readarr_network/downloads/mod.rs @@ -1,7 +1,9 @@ use anyhow::Result; use log::info; +use serde_json::Value; use crate::models::readarr_models::DownloadsResponse; +use crate::models::servarr_models::CommandBody; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; @@ -59,4 +61,22 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn update_readarr_downloads( + &mut self, + ) -> Result { + info!("Updating Readarr downloads"); + let event = ReadarrEvent::UpdateDownloads; + let body = CommandBody { + name: "RefreshMonitoredDownloads".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/readarr_network/downloads/readarr_downloads_network_tests.rs b/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs index 8d90ce7a..d6e4d53b 100644 --- a/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs +++ b/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs @@ -116,4 +116,49 @@ mod tests { stale_downloads ); } + + #[tokio::test] + async fn test_handle_update_readarr_downloads_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "name": "RefreshMonitoredDownloads" })) + .returns(json!({ + "name": "RefreshMonitoredDownloads", + "commandName": "Refresh Monitored Downloads", + "status": "queued" + })) + .build_for(ReadarrEvent::UpdateDownloads) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateDownloads) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_update_readarr_downloads_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "name": "RefreshMonitoredDownloads" })) + .returns(json!({ + "name": "RefreshMonitoredDownloads", + "commandName": "Refresh Monitored Downloads", + "status": "queued" + })) + .status(500) + .build_for(ReadarrEvent::UpdateDownloads) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateDownloads) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 39062509..b8613818 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -65,6 +65,7 @@ pub enum ReadarrEvent { TriggerAutomaticBookSearch(i64), UpdateAllAuthors, UpdateAndScanAuthor(i64), + UpdateDownloads, } impl NetworkResource for ReadarrEvent { @@ -87,7 +88,8 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::TriggerAutomaticAuthorSearch(_) | ReadarrEvent::TriggerAutomaticBookSearch(_) | ReadarrEvent::UpdateAllAuthors - | ReadarrEvent::UpdateAndScanAuthor(_) => "/command", + | ReadarrEvent::UpdateAndScanAuthor(_) + | ReadarrEvent::UpdateDownloads => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::GetBookEditions(_) => "/edition", @@ -254,6 +256,10 @@ impl Network<'_, '_> { .update_and_scan_author(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::UpdateDownloads => self + .update_readarr_downloads() + .await + .map(ReadarrSerdeable::from), } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 7bbfde94..18fd34b3 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -34,6 +34,7 @@ mod tests { #[case(ReadarrEvent::TriggerAutomaticBookSearch(1), "/command")] #[case(ReadarrEvent::UpdateAllAuthors, "/command")] #[case(ReadarrEvent::UpdateAndScanAuthor(1), "/command")] + #[case(ReadarrEvent::UpdateDownloads, "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] From 40be5714e9039b015e313d03c98bbc42bf517e44 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 13:40:40 -0600 Subject: [PATCH 049/170] feat(readarr): fetch Readarr history and expose it on the CLI --- src/cli/readarr/list_command_handler.rs | 12 + src/cli/readarr/list_command_handler_tests.rs | 64 +++++ src/network/readarr_network/history/mod.rs | 41 +++ .../history/readarr_history_network_tests.rs | 266 ++++++++++++++++++ src/network/readarr_network/mod.rs | 7 + .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 391 insertions(+) create mode 100644 src/network/readarr_network/history/mod.rs create mode 100644 src/network/readarr_network/history/readarr_history_network_tests.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 5782e6a5..060ad493 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -77,6 +77,11 @@ pub enum ReadarrListCommand { #[arg(long, help = "How many downloads to fetch", default_value_t = 500)] count: u64, }, + #[command(about = "Fetch all Readarr history events")] + History { + #[arg(long, help = "How many history events to fetch", default_value_t = 500)] + events: u64, + }, #[command(about = "Fetch Readarr logs")] Logs { #[arg(long, help = "How many log events to fetch", default_value_t = 500)] @@ -186,6 +191,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::History { events } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetHistory(events).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Logs { events, output_in_log_format, diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index c230e6b6..c7bca4ff 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -66,6 +66,15 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); } + #[test] + fn test_list_history_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "history", "--events"]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + #[test] fn test_list_author_history_requires_author_id() { let result = @@ -283,6 +292,35 @@ mod tests { }; assert_eq!(downloads_command, expected_args); } + + #[test] + fn test_list_history_default_values() { + let expected_args = ReadarrListCommand::History { events: 500 }; + let result = Cli::try_parse_from(["managarr", "readarr", "list", "history"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(history_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(history_command, expected_args); + } + + #[test] + fn test_list_history_success() { + let expected_args = ReadarrListCommand::History { events: 1000 }; + let result = + Cli::try_parse_from(["managarr", "readarr", "list", "history", "--events", "1000"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(history_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(history_command, expected_args); + } } mod handler { @@ -508,6 +546,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_history_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetHistory(expected_events).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_history_command = ReadarrListCommand::History { events: 1000 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_logs_command() { let expected_events = 1000; diff --git a/src/network/readarr_network/history/mod.rs b/src/network/readarr_network/history/mod.rs new file mode 100644 index 00000000..8d3c2247 --- /dev/null +++ b/src/network/readarr_network/history/mod.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use log::info; + +use crate::models::Route; +use crate::models::readarr_models::ReadarrHistoryWrapper; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_history_network_tests.rs"] +mod readarr_history_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_history( + &mut self, + events: u64, + ) -> Result { + info!("Fetching all Readarr history events"); + let event = ReadarrEvent::GetHistory(events); + + let params = format!("pageSize={events}&sortDirection=descending&sortKey=date"); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), ReadarrHistoryWrapper>(request_props, |history_response, mut app| { + if !matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::HistorySortPrompt, _) + ) { + let mut history_vec = history_response.records; + history_vec.sort_by_key(|item| item.id); + app.data.readarr_data.history.set_items(history_vec); + app.data.readarr_data.history.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/readarr_network/history/readarr_history_network_tests.rs b/src/network/readarr_network/history/readarr_history_network_tests.rs new file mode 100644 index 00000000..666f7286 --- /dev/null +++ b/src/network/readarr_network/history/readarr_history_network_tests.rs @@ -0,0 +1,266 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{ + ReadarrHistoryItem, ReadarrHistoryWrapper, ReadarrSerdeable, + }; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + readarr_history_item, stale_readarr_history_item, + }; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::json; + + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_history_event(#[values(true, false)] use_custom_sorting: bool) { + let history_json = json!({"records": [{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]}); + let anthology_history_item = ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }; + let z_book_history_item = ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }; + let expected_response = ReadarrHistoryWrapper { + records: vec![anthology_history_item.clone(), z_book_history_item.clone()], + }; + let mut expected_history_items = vec![z_book_history_item, anthology_history_item]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + if use_custom_sorting { + let cmp_fn = |a: &ReadarrHistoryItem, b: &ReadarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .readarr_data + .history + .sorting(vec![history_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.history.sort_asc = true; + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryWrapper(history) = result.unwrap() else { + panic!("Expected ReadarrHistoryWrapper") + }; + + assert_eq!(history, expected_response); + let app = app.lock().await; + assert_eq!(app.data.readarr_data.history.items, expected_history_items); + assert!(app.data.readarr_data.history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!({"records": [{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]}); + let expected_response = ReadarrHistoryWrapper { + records: vec![ + ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }, + ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }, + ], + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.history.sort_asc = true; + app.push_navigation_stack(ActiveReadarrBlock::HistorySortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryWrapper(history) = result.unwrap() else { + panic!("Expected ReadarrHistoryWrapper") + }; + + assert_eq!(history, expected_response); + let app = app.lock().await; + assert_is_empty!(app.data.readarr_data.history); + assert!(app.data.readarr_data.history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_history_event_empty_response() { + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({"records": []})) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryWrapper(history) = result.unwrap() else { + panic!("Expected ReadarrHistoryWrapper") + }; + + assert_is_empty!(history.records); + assert_is_empty!(app.lock().await.data.readarr_data.history); + } + + #[tokio::test] + async fn test_handle_get_readarr_history_event_failure() { + let history_json = json!({"records": [{ + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]}); + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .status(500) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.history.items, + stale_history_items + ); + } +} diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index b8613818..229c1c75 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -11,6 +11,7 @@ use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; mod downloads; +mod history; mod library; mod root_folders; mod system; @@ -44,6 +45,7 @@ pub enum ReadarrEvent { GetBooks(i64), GetDiskSpace, GetDownloads(u64), + GetHistory(u64), GetHostConfig, GetLogs(u64), GetMetadataProfiles, @@ -94,6 +96,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::GetBookEditions(_) => "/edition", ReadarrEvent::HealthCheck => "/health", + ReadarrEvent::GetHistory(_) => "/history", ReadarrEvent::GetAuthorHistory(_) | ReadarrEvent::GetBookHistory(_, _) => "/history/author", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", @@ -191,6 +194,10 @@ impl Network<'_, '_> { .get_readarr_downloads(count) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetHistory(events) => self + .get_readarr_history(events) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetHostConfig => self .get_readarr_host_config() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 18fd34b3..062122d1 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -40,6 +40,7 @@ mod tests { #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::GetBookEditions(1), "/edition")] #[case(ReadarrEvent::HealthCheck, "/health")] + #[case(ReadarrEvent::GetHistory(500), "/history")] #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] #[case(ReadarrEvent::GetBookHistory(1, 2), "/history/author")] #[case(ReadarrEvent::GetLogs(500), "/log")] From 1edceba05687bb990056b225cbd6978ba8792009 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 13:46:39 -0600 Subject: [PATCH 050/170] feat(readarr): mark a history item as failed and expose the command on the CLI --- src/cli/readarr/mod.rs | 19 +++++++ src/cli/readarr/readarr_command_tests.rs | 56 +++++++++++++++++++ src/network/readarr_network/history/mod.rs | 23 ++++++++ .../history/readarr_history_network_tests.rs | 41 ++++++++++++++ src/network/readarr_network/mod.rs | 6 ++ .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 146 insertions(+) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 5363005e..b6b325b5 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -8,6 +8,7 @@ use edit_command_handler::{ReadarrEditCommand, ReadarrEditCommandHandler}; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use refresh_command_handler::{ReadarrRefreshCommand, ReadarrRefreshCommandHandler}; +use serde_json::json; use tokio::sync::Mutex; use trigger_automatic_search_command_handler::{ ReadarrTriggerAutomaticSearchCommand, ReadarrTriggerAutomaticSearchCommandHandler, @@ -67,6 +68,15 @@ pub enum ReadarrCommand { about = "Commands to trigger automatic searches for releases of different resources in your Readarr instance" )] TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand), + #[command(about = "Mark the Readarr history item with the given ID as 'failed'")] + MarkHistoryItemAsFailed { + #[arg( + long, + help = "The Readarr ID of the history item you wish to mark as 'failed'", + required = true + )] + history_item_id: i64, + }, #[command(about = "Search for a new author to add to Readarr")] SearchNewAuthor { #[arg( @@ -174,6 +184,15 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::MarkHistoryItemAsFailed { history_item_id } => { + let _ = self + .network + .handle_network_event(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id).into()) + .await?; + serde_json::to_string_pretty( + &json!({"message": "Readarr history item marked as 'failed'"}), + )? + } ReadarrCommand::SearchNewAuthor { query } => { let resp = self .network diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index 9bcc7e4e..afcdf9ed 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -14,6 +14,31 @@ mod tests { assert_err!(&result); } + #[test] + fn test_mark_history_item_as_failed_requires_history_item_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "mark-history-item-as-failed"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_mark_history_item_as_failed_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "mark-history-item-as-failed", + "--history-item-id", + "1234", + ]); + + assert_ok!(&result); + } + #[test] fn test_search_new_author_requires_query() { let result = @@ -146,6 +171,37 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; + #[tokio::test] + async fn test_mark_history_item_as_failed_command() { + let expected_history_item_id = 1234i64; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::MarkHistoryItemAsFailed(expected_history_item_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let mark_history_item_as_failed_command = ReadarrCommand::MarkHistoryItemAsFailed { + history_item_id: expected_history_item_id, + }; + + let result = ReadarrCliHandler::with( + &app_arc, + mark_history_item_as_failed_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_search_new_author_command() { let expected_query = "test author".to_owned(); diff --git a/src/network/readarr_network/history/mod.rs b/src/network/readarr_network/history/mod.rs index 8d3c2247..65def3da 100644 --- a/src/network/readarr_network/history/mod.rs +++ b/src/network/readarr_network/history/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; use log::info; +use serde_json::Value; use crate::models::Route; use crate::models::readarr_models::ReadarrHistoryWrapper; @@ -38,4 +39,26 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn mark_readarr_history_item_as_failed( + &mut self, + history_item_id: i64, + ) -> Result { + info!("Marking the Readarr history item with ID: {history_item_id} as 'failed'"); + let event = ReadarrEvent::MarkHistoryItemAsFailed(history_item_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + None, + Some(format!("/{history_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), Value>(request_props, |_, _| ()) + .await + } } diff --git a/src/network/readarr_network/history/readarr_history_network_tests.rs b/src/network/readarr_network/history/readarr_history_network_tests.rs index 666f7286..dd76afd7 100644 --- a/src/network/readarr_network/history/readarr_history_network_tests.rs +++ b/src/network/readarr_network/history/readarr_history_network_tests.rs @@ -263,4 +263,45 @@ mod tests { stale_history_items ); } + + #[tokio::test] + async fn test_handle_mark_readarr_history_item_as_failed_event() { + let history_item_id = 1234i64; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({})) + .path("/1234") + .build_for(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + + mock.assert_async().await; + + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_mark_readarr_history_item_as_failed_event_failure() { + let history_item_id = 1234i64; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({})) + .status(500) + .path("/1234") + .build_for(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + + mock.assert_async().await; + + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 229c1c75..113a5a56 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -59,6 +59,7 @@ pub enum ReadarrEvent { GetUpdates, HealthCheck, ListAuthors, + MarkHistoryItemAsFailed(i64), SearchNewAuthor(String), StartTask(ReadarrTaskName), ToggleAuthorMonitoring(i64), @@ -98,6 +99,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetHistory(_) => "/history", ReadarrEvent::GetAuthorHistory(_) | ReadarrEvent::GetBookHistory(_, _) => "/history/author", + ReadarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -235,6 +237,10 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::ListAuthors => self.list_authors().await.map(ReadarrSerdeable::from), + ReadarrEvent::MarkHistoryItemAsFailed(history_item_id) => self + .mark_readarr_history_item_as_failed(history_item_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::SearchNewAuthor(query) => { self.search_author(query).await.map(ReadarrSerdeable::from) } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 062122d1..45625eb1 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -43,6 +43,7 @@ mod tests { #[case(ReadarrEvent::GetHistory(500), "/history")] #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] #[case(ReadarrEvent::GetBookHistory(1, 2), "/history/author")] + #[case(ReadarrEvent::MarkHistoryItemAsFailed(1), "/history/failed")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] From 666084a30f63c90d693d597a5b7dd10fb5bae6b2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 14:07:15 -0600 Subject: [PATCH 051/170] feat(readarr): fetch releases for a book and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 17 ++ src/cli/readarr/list_command_handler_tests.rs | 52 ++++++ .../readarr_network/library/books/mod.rs | 32 +++- .../books/readarr_books_network_tests.rs | 155 +++++++++++++++++- src/network/readarr_network/mod.rs | 6 + .../readarr_network_test_utils.rs | 74 ++++++++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 333 insertions(+), 4 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 060ad493..9a27b79b 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -98,6 +98,15 @@ pub enum ReadarrListCommand { QualityProfiles, #[command(about = "List all queued events")] QueuedEvents, + #[command(about = "Trigger a manual search for releases of the book with the given ID")] + Releases { + #[arg( + long, + help = "The Readarr ID of the book whose releases you wish to search for", + required = true + )] + book_id: i64, + }, #[command(about = "List all root folders in Readarr")] RootFolders, #[command(about = "List all Readarr tags")] @@ -236,6 +245,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Releases { book_id } => { + println!("Searching for book releases. This may take a minute..."); + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookReleases(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::RootFolders => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index c7bca4ff..ceb8905a 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -241,6 +241,32 @@ mod tests { assert_eq!(books_command, expected_args); } + #[test] + fn test_list_releases_requires_book_id() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "releases"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_releases_success() { + let expected_args = ReadarrListCommand::Releases { book_id: 7 }; + let result = + Cli::try_parse_from(["managarr", "readarr", "list", "releases", "--book-id", "7"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(releases_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(releases_command, expected_args); + } + #[test] fn test_list_logs_default_values() { let expected_args = ReadarrListCommand::Logs { @@ -705,6 +731,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_releases_command() { + let expected_book_id = 7; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookReleases(expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_releases_command = ReadarrListCommand::Releases { book_id: 7 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_releases_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_root_folders_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index b5979946..8db329fd 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{ - Book, BookFile, DeleteParams, Edition, ReadarrCommandBody, ReadarrHistoryItem, + Book, BookFile, DeleteParams, Edition, ReadarrCommandBody, ReadarrHistoryItem, ReadarrRelease, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::network::readarr_network::ReadarrEvent; @@ -152,6 +152,36 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_book_releases( + &mut self, + book_id: i64, + ) -> Result> { + info!("Fetching releases for Readarr book with ID: {book_id}"); + let event = ReadarrEvent::GetBookReleases(book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("bookId={book_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + let book_details_modal = app + .data + .readarr_data + .book_details_modal + .get_or_insert_default(); + + book_details_modal.book_releases.set_items(release_vec); + }) + .await + } + pub(in crate::network::readarr_network) async fn get_books( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index ed18d2b7..85c84849 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -10,8 +10,9 @@ mod tests { use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - BOOK_FILE_JSON, BOOK_JSON, EDITION_JSON, readarr_history_item, stale_book, stale_book_file, - stale_edition, stale_readarr_history_item, + BOOK_FILE_JSON, BOOK_JSON, EDITION_JSON, RELEASE_JSON, readarr_history_item, stale_book, + stale_book_file, stale_edition, stale_readarr_history_item, stale_release, torrent_release, + usenet_release, }; use mockito::Matcher; use pretty_assertions::assert_eq; @@ -466,6 +467,156 @@ mod tests { ); } + #[tokio::test] + async fn test_handle_get_book_releases_event() { + let expected_releases = vec![torrent_release(), usenet_release()]; + let releases_json = json!([ + serde_json::from_str::(RELEASE_JSON).unwrap(), + { + "guid": "test-usenet-release-guid", + "protocol": "usenet", + "age": 4492, + "title": "Test Author - Test Book [AZW3]", + "authorName": "Test Author", + "bookTitle": "Test Book", + "indexer": "DrunkenSlug", + "indexerId": 4, + "size": 313924185, + "rejected": true, + "rejections": ["Unknown quality profile", "Release is already mapped"], + "quality": { + "quality": { "id": 12, "name": "AZW3" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + } + } + ]); + let (mock, app, _server) = MockServarrApi::get() + .returns(releases_json) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_releases + .set_items(vec![stale_release()]); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_book_releases_event_empty_book_details_modal() { + let expected_releases = vec![torrent_release()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(RELEASE_JSON).unwrap()] + )) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + + let app = app.lock().await; + + assert_some!(&app.data.readarr_data.book_details_modal); + assert_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_book_releases_event_failure() { + let stale_releases = vec![stale_release()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(RELEASE_JSON).unwrap()] + )) + .status(500) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_releases + .set_items(stale_releases.clone()); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + stale_releases + ); + } + #[tokio::test] async fn test_handle_get_books_event() { let books_json = json!([ diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 113a5a56..e5a8bde4 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -42,6 +42,7 @@ pub enum ReadarrEvent { GetBookEditions(i64), GetBookFiles(i64), GetBookHistory(i64, i64), + GetBookReleases(i64), GetBooks(i64), GetDiskSpace, GetDownloads(u64), @@ -104,6 +105,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", ReadarrEvent::DeleteDownload(_) | ReadarrEvent::GetDownloads(_) => "/queue", + ReadarrEvent::GetBookReleases(_) => "/release", ReadarrEvent::AddRootFolder(_) | ReadarrEvent::DeleteRootFolder(_) | ReadarrEvent::GetRootFolders => "/rootfolder", @@ -185,6 +187,10 @@ impl Network<'_, '_> { .get_readarr_book_history(author_id, book_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookReleases(book_id) => self + .get_book_releases(book_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetBooks(author_id) => { self.get_books(author_id).await.map(ReadarrSerdeable::from) } diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index c047d5fd..81156c4d 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -4,7 +4,7 @@ pub mod test_utils { use crate::models::readarr_models::{ AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, BookFile, DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType, NewItemMonitorType, - ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease, }; use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; use chrono::DateTime; @@ -120,6 +120,29 @@ pub mod test_utils { "downloadClient": "NZBGet" }"#; + pub const RELEASE_JSON: &str = r#"{ + "guid": "test-release-guid", + "protocol": "torrent", + "age": 4492, + "title": "Test Author - Test Book [AZW3]", + "authorName": "Test Author", + "bookTitle": "Test Book", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 313924185, + "rejected": true, + "rejections": ["Unknown quality profile", "Release is already mapped"], + "seeders": 2, + "leechers": 1, + "quality": { + "quality": { "id": 12, "name": "AZW3" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + }, + "customFormatScore": 0, + "downloadAllowed": true, + "publishDate": "2014-05-11T14:38:20Z" + }"#; + pub fn stale_author() -> Author { Author { id: 99, @@ -211,6 +234,55 @@ pub mod test_utils { QualityWrapper { quality: quality() } } + pub fn stale_release() -> ReadarrRelease { + ReadarrRelease { + guid: "stale-release-guid".to_owned(), + protocol: "usenet".to_owned(), + title: "Stale Release".into(), + indexer: "Stale Indexer".to_owned(), + indexer_id: 99, + ..ReadarrRelease::default() + } + } + + pub fn rejections() -> Vec { + vec![ + "Unknown quality profile".to_owned(), + "Release is already mapped".to_owned(), + ] + } + + pub fn torrent_release() -> ReadarrRelease { + ReadarrRelease { + guid: "test-release-guid".to_owned(), + protocol: "torrent".to_owned(), + age: 4492, + title: "Test Author - Test Book [AZW3]".into(), + author_name: Some("Test Author".to_owned()), + book_title: Some("Test Book".to_owned()), + indexer: "kickass torrents".to_owned(), + indexer_id: 2, + size: 313924185, + rejected: true, + rejections: Some(rejections()), + seeders: Some(Number::from(2)), + leechers: Some(Number::from(1)), + quality: quality_wrapper(), + } + } + + pub fn usenet_release() -> ReadarrRelease { + ReadarrRelease { + guid: "test-usenet-release-guid".to_owned(), + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + indexer_id: 4, + seeders: None, + leechers: None, + ..torrent_release() + } + } + pub fn quality() -> Quality { Quality { name: "AZW3".to_string(), diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 45625eb1..595c4070 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -49,6 +49,7 @@ mod tests { #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(ReadarrEvent::DeleteDownload(1), "/queue")] #[case(ReadarrEvent::GetDownloads(500), "/queue")] + #[case(ReadarrEvent::GetBookReleases(1), "/release")] #[case( ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()), "/rootfolder" From 999a22a37fc7bed5ae6480803b7fecb098b1f297 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 14:15:28 -0600 Subject: [PATCH 052/170] feat(readarr): fetch releases for an author and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 17 ++++ src/cli/readarr/list_command_handler_tests.rs | 60 ++++++++++++ .../readarr_network/library/authors/mod.rs | 26 ++++- .../authors/readarr_authors_network_tests.rs | 96 ++++++++++++++++++- src/network/readarr_network/mod.rs | 7 +- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 203 insertions(+), 4 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 9a27b79b..95a41f90 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -26,6 +26,15 @@ pub enum ReadarrListCommand { )] author_id: i64, }, + #[command(about = "Trigger a manual search for releases of the author with the given ID")] + AuthorReleases { + #[arg( + long, + help = "The Readarr ID of the author whose releases you wish to search for", + required = true + )] + author_id: i64, + }, #[command(about = "List all authors in your Readarr library")] Authors, #[command(about = "List all editions for the book with the given ID")] @@ -151,6 +160,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::AuthorReleases { author_id } => { + println!("Searching for author releases. This may take a minute..."); + let resp = self + .network + .handle_network_event(ReadarrEvent::GetAuthorReleases(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Authors => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index ceb8905a..cf1ba14e 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -252,6 +252,40 @@ mod tests { ); } + #[test] + fn test_list_author_releases_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "author-releases"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_author_releases_success() { + let expected_args = ReadarrListCommand::AuthorReleases { author_id: 3 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "author-releases", + "--author-id", + "3", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(author_releases_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(author_releases_command, expected_args); + } + #[test] fn test_list_releases_success() { let expected_args = ReadarrListCommand::Releases { book_id: 7 }; @@ -757,6 +791,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_author_releases_command() { + let expected_author_id = 3; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAuthorReleases(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_author_releases_command = ReadarrListCommand::AuthorReleases { author_id: 3 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_author_releases_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_root_folders_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index cd3d1dd0..567e88b4 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{ AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, ReadarrCommandBody, - ReadarrHistoryItem, + ReadarrHistoryItem, ReadarrRelease, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; @@ -217,6 +217,30 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_author_releases( + &mut self, + author_id: i64, + ) -> Result> { + info!("Fetching releases for Readarr author with ID: {author_id}"); + let event = ReadarrEvent::GetAuthorReleases(author_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("authorId={author_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + app.data.readarr_data.author_releases.set_items(release_vec); + }) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_author_history( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index 88c94e6c..99ef12d8 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -10,8 +10,9 @@ mod tests { use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, add_author_body, readarr_history_item, - stale_add_author_search_result, stale_author, stale_readarr_history_item, + ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, RELEASE_JSON, add_author_body, + readarr_history_item, stale_add_author_search_result, stale_author, stale_readarr_history_item, + stale_release, torrent_release, usenet_release, }; use bimap::BiMap; use mockito::Matcher; @@ -76,6 +77,97 @@ mod tests { assert_err!(result); } + #[tokio::test] + async fn test_handle_get_author_releases_event() { + let expected_releases = vec![torrent_release(), usenet_release()]; + let releases_json = json!([ + serde_json::from_str::(RELEASE_JSON).unwrap(), + { + "guid": "test-usenet-release-guid", + "protocol": "usenet", + "age": 4492, + "title": "Test Author - Test Book [AZW3]", + "authorName": "Test Author", + "bookTitle": "Test Book", + "indexer": "DrunkenSlug", + "indexerId": 4, + "size": 313924185, + "rejected": true, + "rejections": ["Unknown quality profile", "Release is already mapped"], + "quality": { + "quality": { "id": 12, "name": "AZW3" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + } + } + ]); + let (mock, app, _server) = MockServarrApi::get() + .returns(releases_json) + .query("authorId=3") + .build_for(ReadarrEvent::GetAuthorReleases(3)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .author_releases + .set_items(vec![stale_release()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorReleases(3)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + assert_eq!( + app.lock().await.data.readarr_data.author_releases.items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_author_releases_event_failure() { + let stale_releases = vec![stale_release()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(RELEASE_JSON).unwrap()] + )) + .status(500) + .query("authorId=3") + .build_for(ReadarrEvent::GetAuthorReleases(3)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .author_releases + .set_items(stale_releases.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorReleases(3)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.author_releases.items, + stale_releases + ); + } + #[rstest] #[tokio::test] async fn test_handle_get_readarr_author_history_event( diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index e5a8bde4..b2c1ce7d 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -38,6 +38,7 @@ pub enum ReadarrEvent { EditAuthor(EditAuthorParams), GetAuthorDetails(i64), GetAuthorHistory(i64), + GetAuthorReleases(i64), GetBookDetails(i64), GetBookEditions(i64), GetBookFiles(i64), @@ -105,7 +106,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", ReadarrEvent::DeleteDownload(_) | ReadarrEvent::GetDownloads(_) => "/queue", - ReadarrEvent::GetBookReleases(_) => "/release", + ReadarrEvent::GetAuthorReleases(_) | ReadarrEvent::GetBookReleases(_) => "/release", ReadarrEvent::AddRootFolder(_) | ReadarrEvent::DeleteRootFolder(_) | ReadarrEvent::GetRootFolders => "/rootfolder", @@ -171,6 +172,10 @@ impl Network<'_, '_> { .get_readarr_author_history(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetAuthorReleases(author_id) => self + .get_author_releases(author_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetBookDetails(book_id) => self .get_book_details(book_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 595c4070..d3637a9c 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -49,6 +49,7 @@ mod tests { #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(ReadarrEvent::DeleteDownload(1), "/queue")] #[case(ReadarrEvent::GetDownloads(500), "/queue")] + #[case(ReadarrEvent::GetAuthorReleases(1), "/release")] #[case(ReadarrEvent::GetBookReleases(1), "/release")] #[case( ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()), From 5a730f2f05f1d3748250ed3bde2689a576a17b1e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 14:22:57 -0600 Subject: [PATCH 053/170] feat(readarr): manually download a release and expose the command on the CLI --- src/cli/readarr/mod.rs | 20 +++++ src/cli/readarr/readarr_command_tests.rs | 81 ++++++++++++++++++ src/network/readarr_network/library/mod.rs | 54 ++++++++++++ .../library/readarr_library_network_tests.rs | 82 +++++++++++++++++++ src/network/readarr_network/mod.rs | 11 ++- .../readarr_network/readarr_network_tests.rs | 6 +- 6 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 src/network/readarr_network/library/readarr_library_network_tests.rs diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index b6b325b5..f0295f61 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -16,6 +16,7 @@ use trigger_automatic_search_command_handler::{ use super::{CliCommandHandler, Command}; use crate::models::readarr_models::ReadarrTaskName; +use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::readarr_network::ReadarrEvent; use crate::{app::App, network::NetworkTrait}; @@ -68,6 +69,17 @@ pub enum ReadarrCommand { about = "Commands to trigger automatic searches for releases of different resources in your Readarr instance" )] TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand), + #[command(about = "Manually download the given release")] + DownloadRelease { + #[arg(long, help = "The GUID of the release to download", required = true)] + guid: String, + #[arg( + long, + help = "The indexer ID to download the release from", + required = true + )] + indexer_id: i64, + }, #[command(about = "Mark the Readarr history item with the given ID as 'failed'")] MarkHistoryItemAsFailed { #[arg( @@ -184,6 +196,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::DownloadRelease { guid, indexer_id } => { + let params = ReleaseDownloadBody { guid, indexer_id }; + let resp = self + .network + .handle_network_event(ReadarrEvent::DownloadRelease(params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrCommand::MarkHistoryItemAsFailed { history_item_id } => { let _ = self .network diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index afcdf9ed..a4a19013 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -26,6 +26,55 @@ mod tests { ); } + #[test] + fn test_download_release_requires_guid() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "download-release", + "--indexer-id", + "6", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_release_requires_indexer_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "download-release", + "--guid", + "test-release-guid", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_release_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "download-release", + "--guid", + "test-release-guid", + "--indexer-id", + "6", + ]); + + assert_ok!(&result); + } + #[test] fn test_mark_history_item_as_failed_requirements_satisfied() { let result = Cli::command().try_get_matches_from([ @@ -161,6 +210,7 @@ mod tests { use tokio::sync::Mutex; use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; + use crate::models::servarr_models::ReleaseDownloadBody; use crate::{ app::App, cli::{ @@ -202,6 +252,37 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_download_release_command() { + let expected_release_download_body = ReleaseDownloadBody { + guid: "test-release-guid".to_owned(), + indexer_id: 6, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::DownloadRelease(expected_release_download_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let download_release_command = ReadarrCommand::DownloadRelease { + guid: "test-release-guid".to_owned(), + indexer_id: 6, + }; + + let result = ReadarrCliHandler::with(&app_arc, download_release_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_search_new_author_command() { let expected_query = "test author".to_owned(); diff --git a/src/network/readarr_network/library/mod.rs b/src/network/readarr_network/library/mod.rs index c2fc5060..bf8c3725 100644 --- a/src/network/readarr_network/library/mod.rs +++ b/src/network/readarr_network/library/mod.rs @@ -1,2 +1,56 @@ +use crate::models::servarr_data::Notification; +use crate::models::servarr_models::ReleaseDownloadBody; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; +use serde_json::Value; + mod authors; mod books; + +#[cfg(test)] +#[path = "readarr_library_network_tests.rs"] +mod readarr_library_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn download_readarr_release( + &mut self, + release_download_body: ReleaseDownloadBody, + ) -> Result { + let event = ReadarrEvent::DownloadRelease(ReleaseDownloadBody::default()); + info!("Downloading Readarr release with params: {release_download_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(release_download_body), + None, + None, + ) + .await; + + let result = self + .handle_request::(request_props, |_, mut app| { + app.notification = Some(Notification::new( + "Download Result".to_owned(), + "Download request sent successfully".to_owned(), + true, + )); + }) + .await; + + if result.is_err() { + let mut app = self.app.lock().await; + std::mem::take(&mut app.error.text); + app.notification = Some(Notification::new( + "Download Failed".to_owned(), + "Download request failed. Check the logs for more details.".to_owned(), + false, + )); + } + + result + } +} diff --git a/src/network/readarr_network/library/readarr_library_network_tests.rs b/src/network/readarr_network/library/readarr_library_network_tests.rs new file mode 100644 index 00000000..7268e40c --- /dev/null +++ b/src/network/readarr_network/library/readarr_library_network_tests.rs @@ -0,0 +1,82 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::Notification; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_download_readarr_release_event_uses_provided_params() { + let params = ReleaseDownloadBody { + guid: "test-release-guid".to_owned(), + indexer_id: 6, + }; + + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "guid": "test-release-guid", + "indexerId": 6, + })) + .returns(json!({ "id": 3 })) + .build_for(ReadarrEvent::DownloadRelease(params.clone())) + .await; + + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DownloadRelease(params)) + .await; + + mock.assert_async().await; + assert_ok!(result); + assert_eq!( + app.lock().await.notification, + Some(Notification::new( + "Download Result".to_owned(), + "Download request sent successfully".to_owned(), + true, + )) + ); + } + + #[tokio::test] + async fn test_handle_download_readarr_release_event_sets_failure_notification_on_error() { + let params = ReleaseDownloadBody { + guid: "test-release-guid".to_owned(), + indexer_id: 6, + }; + + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "guid": "test-release-guid", + "indexerId": 6, + })) + .returns(json!({ "id": 3 })) + .status(500) + .build_for(ReadarrEvent::DownloadRelease(params.clone())) + .await; + + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DownloadRelease(params)) + .await; + + mock.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_is_empty!(app.error.text); + assert_some_eq_x!( + &app.notification, + &Notification::new( + "Download Failed".to_owned(), + "Download request failed. Check the logs for more details.".to_owned(), + false, + ) + ); + } +} diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index b2c1ce7d..8a1eadb7 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -7,7 +7,7 @@ use crate::models::readarr_models::{ AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, ReadarrTaskName, }; -use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; +use crate::models::servarr_models::{MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag}; use crate::network::{Network, RequestMethod}; mod downloads; @@ -35,6 +35,7 @@ pub enum ReadarrEvent { DeleteDownload(i64), DeleteRootFolder(i64), DeleteTag(i64), + DownloadRelease(ReleaseDownloadBody), EditAuthor(EditAuthorParams), GetAuthorDetails(i64), GetAuthorHistory(i64), @@ -106,7 +107,9 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", ReadarrEvent::DeleteDownload(_) | ReadarrEvent::GetDownloads(_) => "/queue", - ReadarrEvent::GetAuthorReleases(_) | ReadarrEvent::GetBookReleases(_) => "/release", + ReadarrEvent::DownloadRelease(_) + | ReadarrEvent::GetAuthorReleases(_) + | ReadarrEvent::GetBookReleases(_) => "/release", ReadarrEvent::AddRootFolder(_) | ReadarrEvent::DeleteRootFolder(_) | ReadarrEvent::GetRootFolders => "/rootfolder", @@ -160,6 +163,10 @@ impl Network<'_, '_> { .delete_readarr_tag(tag_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::DownloadRelease(release_download_body) => self + .download_readarr_release(release_download_body) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::EditAuthor(edit_author_params) => self .edit_author(edit_author_params) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d3637a9c..d4fa1326 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::readarr_models::{ AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, }; - use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; + use crate::models::servarr_models::{MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; use bimap::BiMap; @@ -49,6 +49,10 @@ mod tests { #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(ReadarrEvent::DeleteDownload(1), "/queue")] #[case(ReadarrEvent::GetDownloads(500), "/queue")] + #[case( + ReadarrEvent::DownloadRelease(ReleaseDownloadBody::default()), + "/release" + )] #[case(ReadarrEvent::GetAuthorReleases(1), "/release")] #[case(ReadarrEvent::GetBookReleases(1), "/release")] #[case( From 5c593b3770da97b3952c2b2dc5dfa6789733399c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 1 Sep 2026 16:32:13 -0600 Subject: [PATCH 054/170] feat(readarr): fetch the blocklist and expose it on the CLI --- src/cli/readarr/list_command_handler.rs | 9 + src/cli/readarr/list_command_handler_tests.rs | 24 ++ src/network/readarr_network/blocklist/mod.rs | 39 +++ .../readarr_blocklist_network_tests.rs | 226 ++++++++++++++++++ src/network/readarr_network/mod.rs | 7 + .../readarr_network_test_utils.rs | 31 ++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 src/network/readarr_network/blocklist/mod.rs create mode 100644 src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 95a41f90..1e198efc 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -37,6 +37,8 @@ pub enum ReadarrListCommand { }, #[command(about = "List all authors in your Readarr library")] Authors, + #[command(about = "List all items in the Readarr blocklist")] + Blocklist, #[command(about = "List all editions for the book with the given ID")] BookEditions { #[arg( @@ -175,6 +177,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Blocklist => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::BookEditions { book_id } => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index cf1ba14e..b74c1c38 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -27,6 +27,7 @@ mod tests { fn test_list_commands_have_no_arg_requirements( #[values( "authors", + "blocklist", "disk-space", "metadata-profiles", "quality-profiles", @@ -424,6 +425,29 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_blocklist_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_blocklist_command = ReadarrListCommand::Blocklist; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_blocklist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_author_history_command() { let expected_author_id = 1; diff --git a/src/network/readarr_network/blocklist/mod.rs b/src/network/readarr_network/blocklist/mod.rs new file mode 100644 index 00000000..1268b0c7 --- /dev/null +++ b/src/network/readarr_network/blocklist/mod.rs @@ -0,0 +1,39 @@ +use anyhow::Result; +use log::info; + +use crate::models::Route; +use crate::models::readarr_models::{BlocklistItem, BlocklistResponse}; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_blocklist_network_tests.rs"] +mod readarr_blocklist_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_blocklist( + &mut self, + ) -> Result { + info!("Fetching Readarr blocklist"); + let event = ReadarrEvent::GetBlocklist; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| { + if !matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::BlocklistSortPrompt, _) + ) { + let mut blocklist_vec: Vec = blocklist_resp.records; + blocklist_vec.sort_by_key(|item| item.id); + app.data.readarr_data.blocklist.set_items(blocklist_vec); + app.data.readarr_data.blocklist.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs new file mode 100644 index 00000000..e005fae9 --- /dev/null +++ b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs @@ -0,0 +1,226 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{BlocklistItem, BlocklistResponse, ReadarrSerdeable}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + AUTHOR_JSON, blocklist_item, stale_blocklist_item, + }; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::{Number, Value, json}; + + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_blocklist_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let blocklist_json = json!({"records": [ + { + "id": 456, + "authorId": 2001, + "bookIds": [42020], + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + }, + { + "id": 123, + "authorId": 1007, + "bookIds": [42018], + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + } + ]}); + let anthology_blocklist_item = BlocklistItem { + id: 456, + author_id: 2001, + book_ids: Some(vec![Number::from(42020)]), + source_title: "An Anthology".to_owned(), + ..blocklist_item() + }; + let z_book_blocklist_item = BlocklistItem { + id: 123, + author_id: 1007, + book_ids: Some(vec![Number::from(42018)]), + source_title: "z book".to_owned(), + ..blocklist_item() + }; + let expected_response = BlocklistResponse { + records: vec![ + anthology_blocklist_item.clone(), + z_book_blocklist_item.clone(), + ], + }; + let mut expected_blocklist_items = vec![z_book_blocklist_item, anthology_blocklist_item]; + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .build_for(ReadarrEvent::GetBlocklist) + .await; + if use_custom_sorting { + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + expected_blocklist_items.sort_by(cmp_fn); + + let blocklist_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .readarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.sort_asc = true; + app + .data + .readarr_data + .blocklist + .set_items(vec![stale_blocklist_item()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBlocklist) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BlocklistResponse(blocklist) = result.unwrap() else { + panic!("Expected BlocklistResponse") + }; + + assert_eq!(blocklist, expected_response); + let app = app.lock().await; + assert_eq!( + app.data.readarr_data.blocklist.items, + expected_blocklist_items + ); + assert!(app.data.readarr_data.blocklist.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_blocklist_event_no_op_when_user_is_selecting_sort_options() { + let stale_blocklist_items = vec![stale_blocklist_item()]; + let blocklist_json = json!({"records": [ + { + "id": 456, + "authorId": 2001, + "bookIds": [42020], + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + } + ]}); + let expected_response = BlocklistResponse { + records: vec![BlocklistItem { + id: 456, + author_id: 2001, + book_ids: Some(vec![Number::from(42020)]), + source_title: "An Anthology".to_owned(), + ..blocklist_item() + }], + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .build_for(ReadarrEvent::GetBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.sort_asc = true; + app + .data + .readarr_data + .blocklist + .set_items(stale_blocklist_items.clone()); + app.push_navigation_stack(ActiveReadarrBlock::BlocklistSortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBlocklist) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BlocklistResponse(blocklist) = result.unwrap() else { + panic!("Expected BlocklistResponse") + }; + + assert_eq!(blocklist, expected_response); + let app = app.lock().await; + assert_eq!(app.data.readarr_data.blocklist.items, stale_blocklist_items); + assert!(app.data.readarr_data.blocklist.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_blocklist_event_failure() { + let stale_blocklist_items = vec![stale_blocklist_item()]; + let blocklist_json = json!({"records": [ + { + "id": 456, + "authorId": 2001, + "bookIds": [42020], + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + } + ]}); + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .status(500) + .build_for(ReadarrEvent::GetBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .blocklist + .set_items(stale_blocklist_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBlocklist) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.blocklist.items, + stale_blocklist_items + ); + } +} diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 8a1eadb7..17c142e5 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -10,6 +10,7 @@ use crate::models::readarr_models::{ use crate::models::servarr_models::{MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag}; use crate::network::{Network, RequestMethod}; +mod blocklist; mod downloads; mod history; mod library; @@ -40,6 +41,7 @@ pub enum ReadarrEvent { GetAuthorDetails(i64), GetAuthorHistory(i64), GetAuthorReleases(i64), + GetBlocklist, GetBookDetails(i64), GetBookEditions(i64), GetBookFiles(i64), @@ -84,6 +86,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", + ReadarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", ReadarrEvent::DeleteBook(_) | ReadarrEvent::GetBookDetails(_) | ReadarrEvent::GetBooks(_) @@ -183,6 +186,10 @@ impl Network<'_, '_> { .get_author_releases(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBlocklist => self + .get_readarr_blocklist() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetBookDetails(book_id) => self .get_book_details(book_id) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 81156c4d..7bdee11d 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -2,9 +2,10 @@ #[allow(dead_code)] pub mod test_utils { use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, BookFile, - DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType, NewItemMonitorType, - ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease, + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, BlocklistItem, + Book, BookFile, DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType, + NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, + ReadarrRelease, }; use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; use chrono::DateTime; @@ -234,6 +235,30 @@ pub mod test_utils { QualityWrapper { quality: quality() } } + pub fn stale_blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 99, + author_id: 99, + source_title: "Stale source title".to_owned(), + ..BlocklistItem::default() + } + } + + pub fn blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 1, + author_id: 1, + book_ids: Some(vec![Number::from(1)]), + source_title: "Test source title".to_owned(), + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + message: "test message".to_owned(), + author: serde_json::from_str(AUTHOR_JSON).unwrap(), + } + } + pub fn stale_release() -> ReadarrRelease { ReadarrRelease { guid: "stale-release-guid".to_owned(), diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d4fa1326..5f3bdd5e 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -22,6 +22,7 @@ mod tests { #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] + #[case(ReadarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(ReadarrEvent::DeleteBook(DeleteParams::default()), "/book")] #[case(ReadarrEvent::GetBookDetails(1), "/book")] #[case(ReadarrEvent::GetBooks(1), "/book")] From fb91c2edd61d3241a809d5c21f74fd00b501dcbf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 1 Sep 2026 16:38:09 -0600 Subject: [PATCH 055/170] feat(readarr): delete an item from the blocklist and expose the command on the CLI --- src/cli/readarr/delete_command_handler.rs | 16 ++++ .../readarr/delete_command_handler_tests.rs | 81 +++++++++++++++++++ src/network/readarr_network/blocklist/mod.rs | 22 +++++ .../readarr_blocklist_network_tests.rs | 35 ++++++++ src/network/readarr_network/mod.rs | 6 ++ .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 161 insertions(+) diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs index 35ef2188..cf280076 100644 --- a/src/cli/readarr/delete_command_handler.rs +++ b/src/cli/readarr/delete_command_handler.rs @@ -27,6 +27,15 @@ pub enum ReadarrDeleteCommand { #[arg(long, help = "Add a list exclusion for this author")] add_list_exclusion: bool, }, + #[command(about = "Delete the specified item from the Readarr blocklist")] + BlocklistItem { + #[arg( + long, + help = "The ID of the blocklist item to remove from the blocklist", + required = true + )] + blocklist_item_id: i64, + }, #[command(about = "Delete a book from your Readarr library")] Book { #[arg(long, help = "The ID of the book to delete", required = true)] @@ -103,6 +112,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> .await?; serde_json::to_string_pretty(&resp)? } + ReadarrDeleteCommand::BlocklistItem { blocklist_item_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteBlocklistItem(blocklist_item_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrDeleteCommand::Book { book_id, delete_files_from_disk, diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs index 3b763ee9..5cb6b2b2 100644 --- a/src/cli/readarr/delete_command_handler_tests.rs +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -104,6 +104,57 @@ mod tests { assert_eq!(delete_command, expected_args); } + #[test] + fn test_delete_blocklist_item_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "blocklist-item"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_blocklist_item_blocklist_item_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "blocklist-item", + "--blocklist-item-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_blocklist_item_success() { + let expected_args = ReadarrDeleteCommand::BlocklistItem { + blocklist_item_id: 7, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "blocklist-item", + "--blocklist-item-id", + "7", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + #[test] fn test_delete_book_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "book"]); @@ -453,6 +504,36 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_delete_blocklist_item_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::DeleteBlocklistItem(7).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_blocklist_item_command = ReadarrDeleteCommand::BlocklistItem { + blocklist_item_id: 7, + }; + + let result = ReadarrDeleteCommandHandler::with( + &app_arc, + delete_blocklist_item_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_delete_book_file_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/blocklist/mod.rs b/src/network/readarr_network/blocklist/mod.rs index 1268b0c7..4a773997 100644 --- a/src/network/readarr_network/blocklist/mod.rs +++ b/src/network/readarr_network/blocklist/mod.rs @@ -12,6 +12,28 @@ use crate::network::{Network, RequestMethod}; mod readarr_blocklist_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn delete_readarr_blocklist_item( + &mut self, + blocklist_item_id: i64, + ) -> Result<()> { + info!("Deleting Readarr blocklist item for item with id: {blocklist_item_id}"); + let event = ReadarrEvent::DeleteBlocklistItem(blocklist_item_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{blocklist_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_blocklist( &mut self, ) -> Result { diff --git a/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs index e005fae9..042cf920 100644 --- a/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs +++ b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs @@ -12,6 +12,41 @@ mod tests { use rstest::rstest; use serde_json::{Number, Value, json}; + #[tokio::test] + async fn test_handle_delete_readarr_blocklist_item_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/7") + .build_for(ReadarrEvent::DeleteBlocklistItem(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBlocklistItem(7)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_blocklist_item_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/7") + .status(500) + .build_for(ReadarrEvent::DeleteBlocklistItem(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBlocklistItem(7)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[rstest] #[tokio::test] async fn test_handle_get_readarr_blocklist_event( diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 17c142e5..1e8bcd70 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -31,6 +31,7 @@ pub enum ReadarrEvent { AddRootFolder(AddReadarrRootFolderBody), AddTag(String), DeleteAuthor(DeleteParams), + DeleteBlocklistItem(i64), DeleteBook(DeleteParams), DeleteBookFile(i64), DeleteDownload(i64), @@ -86,6 +87,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", + ReadarrEvent::DeleteBlocklistItem(_) => "/blocklist", ReadarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", ReadarrEvent::DeleteBook(_) | ReadarrEvent::GetBookDetails(_) @@ -146,6 +148,10 @@ impl Network<'_, '_> { .delete_author(delete_author_params) .await .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteBlocklistItem(blocklist_item_id) => self + .delete_readarr_blocklist_item(blocklist_item_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::DeleteBook(delete_book_params) => self .delete_book(delete_book_params) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 5f3bdd5e..4278fe85 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -22,6 +22,7 @@ mod tests { #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] + #[case(ReadarrEvent::DeleteBlocklistItem(1), "/blocklist")] #[case(ReadarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(ReadarrEvent::DeleteBook(DeleteParams::default()), "/book")] #[case(ReadarrEvent::GetBookDetails(1), "/book")] From c94b2a40d006e1ed1e5a021755cd51d229fdf67c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 1 Sep 2026 16:44:07 -0600 Subject: [PATCH 056/170] feat(readarr): clear the blocklist and expose the command on the CLI --- src/cli/readarr/mod.rs | 13 ++++ src/cli/readarr/readarr_command_tests.rs | 44 +++++++++++- src/network/readarr_network/blocklist/mod.rs | 34 +++++++++ .../readarr_blocklist_network_tests.rs | 71 +++++++++++++++++++ src/network/readarr_network/mod.rs | 6 ++ .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index f0295f61..774dc62c 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -69,6 +69,8 @@ pub enum ReadarrCommand { about = "Commands to trigger automatic searches for releases of different resources in your Readarr instance" )] TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand), + #[command(about = "Clear the Readarr blocklist")] + ClearBlocklist, #[command(about = "Manually download the given release")] DownloadRelease { #[arg(long, help = "The GUID of the release to download", required = true)] @@ -196,6 +198,17 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::ClearBlocklist => { + self + .network + .handle_network_event(ReadarrEvent::GetBlocklist.into()) + .await?; + let resp = self + .network + .handle_network_event(ReadarrEvent::ClearBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrCommand::DownloadRelease { guid, indexer_id } => { let params = ReleaseDownloadBody { guid, indexer_id }; let resp = self diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index a4a19013..c61d1cd2 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -188,6 +188,13 @@ mod tests { ); } + #[test] + fn test_clear_blocklist_has_no_arg_requirements() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "clear-blocklist"]); + + assert_ok!(&result); + } + #[test] fn test_toggle_book_monitoring_requirements_satisfied() { let result = Cli::command().try_get_matches_from([ @@ -209,7 +216,9 @@ mod tests { use serde_json::json; use tokio::sync::Mutex; - use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; + use crate::models::readarr_models::{ + BlocklistItem, BlocklistResponse, ReadarrSerdeable, ReadarrTaskName, + }; use crate::models::servarr_models::ReleaseDownloadBody; use crate::{ app::App, @@ -390,5 +399,38 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_clear_blocklist_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::BlocklistResponse( + BlocklistResponse { + records: vec![BlocklistItem::default()], + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::ClearBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let clear_blocklist_command = ReadarrCommand::ClearBlocklist; + + let result = ReadarrCliHandler::with(&app_arc, clear_blocklist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/blocklist/mod.rs b/src/network/readarr_network/blocklist/mod.rs index 4a773997..15f98037 100644 --- a/src/network/readarr_network/blocklist/mod.rs +++ b/src/network/readarr_network/blocklist/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; use log::info; +use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{BlocklistItem, BlocklistResponse}; @@ -12,6 +13,39 @@ use crate::network::{Network, RequestMethod}; mod readarr_blocklist_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn clear_readarr_blocklist( + &mut self, + ) -> Result<()> { + info!("Clearing Readarr blocklist"); + let event = ReadarrEvent::ClearBlocklist; + + let ids = self + .app + .lock() + .await + .data + .readarr_data + .blocklist + .items + .iter() + .map(|item| item.id) + .collect::>(); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + Some(json!({ "ids": ids })), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn delete_readarr_blocklist_item( &mut self, blocklist_item_id: i64, diff --git a/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs index 042cf920..e2f56609 100644 --- a/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs +++ b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs @@ -12,6 +12,77 @@ mod tests { use rstest::rstest; use serde_json::{Number, Value, json}; + #[tokio::test] + async fn test_handle_clear_readarr_blocklist_event() { + let blocklist_items = vec![ + BlocklistItem { + id: 7, + ..blocklist_item() + }, + BlocklistItem { + id: 42, + ..blocklist_item() + }, + BlocklistItem { + id: 99, + ..blocklist_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::delete() + .with_request_body(json!({ "ids": [7, 42, 99] })) + .build_for(ReadarrEvent::ClearBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.set_items(blocklist_items); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ClearBlocklist) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_clear_readarr_blocklist_event_failure() { + let blocklist_items = vec![ + BlocklistItem { + id: 7, + ..blocklist_item() + }, + BlocklistItem { + id: 42, + ..blocklist_item() + }, + BlocklistItem { + id: 99, + ..blocklist_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::delete() + .with_request_body(json!({ "ids": [7, 42, 99] })) + .status(500) + .build_for(ReadarrEvent::ClearBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.set_items(blocklist_items); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ClearBlocklist) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_delete_readarr_blocklist_item_event() { let (mock, app, _server) = MockServarrApi::delete() diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 1e8bcd70..4a35ee10 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -30,6 +30,7 @@ pub enum ReadarrEvent { AddAuthor(AddAuthorBody), AddRootFolder(AddReadarrRootFolderBody), AddTag(String), + ClearBlocklist, DeleteAuthor(DeleteParams), DeleteBlocklistItem(i64), DeleteBook(DeleteParams), @@ -88,6 +89,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", ReadarrEvent::DeleteBlocklistItem(_) => "/blocklist", + ReadarrEvent::ClearBlocklist => "/blocklist/bulk", ReadarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", ReadarrEvent::DeleteBook(_) | ReadarrEvent::GetBookDetails(_) @@ -144,6 +146,10 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::AddTag(tag) => self.add_readarr_tag(tag).await.map(ReadarrSerdeable::from), + ReadarrEvent::ClearBlocklist => self + .clear_readarr_blocklist() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::DeleteAuthor(delete_author_params) => self .delete_author(delete_author_params) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 4278fe85..01f76e60 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -23,6 +23,7 @@ mod tests { #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] #[case(ReadarrEvent::DeleteBlocklistItem(1), "/blocklist")] + #[case(ReadarrEvent::ClearBlocklist, "/blocklist/bulk")] #[case(ReadarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(ReadarrEvent::DeleteBook(DeleteParams::default()), "/book")] #[case(ReadarrEvent::GetBookDetails(1), "/book")] From be5826f2c460e98c80615daced55e45588c13a6d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 1 Sep 2026 16:59:57 -0600 Subject: [PATCH 057/170] feat(readarr): fetch all indexers and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 9 +++ src/cli/readarr/list_command_handler_tests.rs | 24 ++++++ src/network/readarr_network/indexers/mod.rs | 28 +++++++ .../readarr_indexers_network_tests.rs | 81 +++++++++++++++++++ src/network/readarr_network/mod.rs | 7 ++ .../readarr_network_test_utils.rs | 70 +++++++++++++++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/network/readarr_network/indexers/mod.rs create mode 100644 src/network/readarr_network/indexers/readarr_indexers_network_tests.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 1e198efc..265ef2ac 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -93,6 +93,8 @@ pub enum ReadarrListCommand { #[arg(long, help = "How many history events to fetch", default_value_t = 500)] events: u64, }, + #[command(about = "List all Readarr indexers")] + Indexers, #[command(about = "Fetch Readarr logs")] Logs { #[arg(long, help = "How many log events to fetch", default_value_t = 500)] @@ -233,6 +235,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Indexers => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Logs { events, output_in_log_format, diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index b74c1c38..1b11fb5b 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -29,6 +29,7 @@ mod tests { "authors", "blocklist", "disk-space", + "indexers", "metadata-profiles", "quality-profiles", "queued-events", @@ -656,6 +657,29 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_indexers_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetIndexers.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_indexers_command = ReadarrListCommand::Indexers; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_indexers_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_logs_command() { let expected_events = 1000; diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs new file mode 100644 index 00000000..3b9b8db5 --- /dev/null +++ b/src/network/readarr_network/indexers/mod.rs @@ -0,0 +1,28 @@ +use crate::models::servarr_models::Indexer; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; + +#[cfg(test)] +#[path = "readarr_indexers_network_tests.rs"] +mod readarr_indexers_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_indexers( + &mut self, + ) -> Result> { + info!("Fetching Readarr indexers"); + let event = ReadarrEvent::GetIndexers; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |indexers, mut app| { + app.data.readarr_data.indexers.set_items(indexers); + }) + .await + } +} diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs new file mode 100644 index 00000000..18f7016a --- /dev/null +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -0,0 +1,81 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::servarr_models::Indexer; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + INDEXER_JSON, indexer, stale_indexer, + }; + use pretty_assertions::assert_eq; + use serde_json::{Value, json}; + + #[tokio::test] + async fn test_handle_get_readarr_indexers_event() { + let indexers_response_json = json!([serde_json::from_str::(INDEXER_JSON).unwrap()]); + let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(indexers_response_json) + .build_for(ReadarrEvent::GetIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .indexers + .set_items(vec![stale_indexer()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetIndexers) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Indexers(indexers) = result.unwrap() else { + panic!("Expected Indexers") + }; + + assert_eq!(indexers, response); + assert_eq!( + app.lock().await.data.readarr_data.indexers.items, + vec![indexer()] + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_indexers_event_failure() { + let stale_indexers = vec![stale_indexer()]; + let indexers_response_json = json!([serde_json::from_str::(INDEXER_JSON).unwrap()]); + let (mock, app, _server) = MockServarrApi::get() + .returns(indexers_response_json) + .status(500) + .build_for(ReadarrEvent::GetIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .indexers + .set_items(stale_indexers.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetIndexers) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.indexers.items, + stale_indexers + ); + } +} diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 4a35ee10..91ce827a 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -13,6 +13,7 @@ use crate::network::{Network, RequestMethod}; mod blocklist; mod downloads; mod history; +mod indexers; mod library; mod root_folders; mod system; @@ -54,6 +55,7 @@ pub enum ReadarrEvent { GetDownloads(u64), GetHistory(u64), GetHostConfig, + GetIndexers, GetLogs(u64), GetMetadataProfiles, GetQualityProfiles, @@ -110,6 +112,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetHistory(_) => "/history", ReadarrEvent::GetAuthorHistory(_) | ReadarrEvent::GetBookHistory(_, _) => "/history/author", ReadarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", + ReadarrEvent::GetIndexers => "/indexer", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -241,6 +244,10 @@ impl Network<'_, '_> { .get_readarr_host_config() .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetIndexers => self + .get_readarr_indexers() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetLogs(events) => self .get_readarr_logs(events) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 7bdee11d..b95342b5 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -7,9 +7,9 @@ pub mod test_utils { NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease, }; - use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; + use crate::models::servarr_models::{Indexer, IndexerField, Quality, QualityWrapper, RootFolder}; use chrono::DateTime; - use serde_json::Number; + use serde_json::{Number, json}; pub const AUTHOR_JSON: &str = r#"{ "id": 1, @@ -144,6 +144,29 @@ pub mod test_utils { "publishDate": "2014-05-11T14:38:20Z" }"#; + pub const INDEXER_JSON: &str = r#"{ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "supportsRss": true, + "supportsSearch": true, + "protocol": "torrent", + "priority": 25, + "downloadClientId": 0, + "name": "Test Indexer", + "implementationName": "Torznab", + "implementation": "Torznab", + "configContract": "TorznabSettings", + "infoLink": "https://wiki.servarr.com/readarr/supported#torznab", + "fields": [ + { "name": "baseUrl", "value": "https://test.com" }, + { "name": "apiKey", "value": "" }, + { "name": "seedCriteria.seedRatio", "value": "1.2" } + ], + "tags": [1], + "id": 8 + }"#; + pub fn stale_author() -> Author { Author { id: 99, @@ -373,4 +396,47 @@ pub mod test_utils { records: vec![download_record()], } } + + pub fn stale_indexer() -> Indexer { + Indexer { + id: 99, + name: Some("Stale Indexer".to_owned()), + protocol: "usenet".to_owned(), + priority: 99, + ..Indexer::default() + } + } + + pub fn indexer() -> Indexer { + Indexer { + id: 8, + name: Some("Test Indexer".to_owned()), + implementation: Some("Torznab".to_owned()), + implementation_name: Some("Torznab".to_owned()), + config_contract: Some("TorznabSettings".to_owned()), + supports_rss: true, + supports_search: true, + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(json!("https://test.com")), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(json!("")), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(json!("1.2")), + }, + ]), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + protocol: "torrent".to_owned(), + priority: 25, + download_client_id: 0, + tags: vec![Number::from(1)], + } + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 01f76e60..16ef7bad 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -47,6 +47,7 @@ mod tests { #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] #[case(ReadarrEvent::GetBookHistory(1, 2), "/history/author")] #[case(ReadarrEvent::MarkHistoryItemAsFailed(1), "/history/failed")] + #[case(ReadarrEvent::GetIndexers, "/indexer")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] From daf991d1eb4b2d66cd407b0b41b322f15698e0ff Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 1 Sep 2026 17:11:32 -0600 Subject: [PATCH 058/170] feat(readarr): edit an indexer and expose the command on the CLI --- src/cli/readarr/edit_command_handler.rs | 134 +++++++ src/cli/readarr/edit_command_handler_tests.rs | 331 ++++++++++++++++++ src/network/readarr_network/indexers/mod.rs | 222 +++++++++++- .../readarr_indexers_network_tests.rs | 314 ++++++++++++++++- src/network/readarr_network/mod.rs | 11 +- .../readarr_network_test_utils.rs | 30 +- .../readarr_network/readarr_network_tests.rs | 5 +- 7 files changed, 1037 insertions(+), 10 deletions(-) diff --git a/src/cli/readarr/edit_command_handler.rs b/src/cli/readarr/edit_command_handler.rs index 0e8c808d..d6cdb540 100644 --- a/src/cli/readarr/edit_command_handler.rs +++ b/src/cli/readarr/edit_command_handler.rs @@ -5,6 +5,7 @@ use clap::{ArgAction, ArgGroup, Subcommand}; use tokio::sync::Mutex; use super::ReadarrCommand; +use crate::models::servarr_models::EditIndexerParams; use crate::{ app::App, cli::{CliCommandHandler, Command, mutex_flags_or_option}, @@ -79,6 +80,97 @@ pub enum ReadarrEditCommand { #[arg(long, help = "Clear all tags on this author", conflicts_with = "tag")] clear_tags: bool, }, + #[command( + about = "Edit preferences for the specified indexer", + group( + ArgGroup::new("edit_indexer") + .args([ + "name", + "enable_rss", + "disable_rss", + "enable_automatic_search", + "disable_automatic_search", + "enable_interactive_search", + "disable_interactive_search", + "url", + "api_key", + "seed_ratio", + "tag", + "priority", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Indexer { + #[arg( + long, + help = "The ID of the indexer whose settings you wish to edit", + required = true + )] + indexer_id: i64, + #[arg(long, help = "The name of the indexer")] + name: Option, + #[arg( + long, + help = "Indicate to Readarr that this indexer should be used when Readarr periodically looks for releases via RSS Sync", + conflicts_with = "disable_rss" + )] + enable_rss: bool, + #[arg( + long, + help = "Disable using this indexer when Readarr periodically looks for releases via RSS Sync", + conflicts_with = "enable_rss" + )] + disable_rss: bool, + #[arg( + long, + help = "Indicate to Readarr that this indexer should be used when automatic searches are performed via the UI or by Readarr", + conflicts_with = "disable_automatic_search" + )] + enable_automatic_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever automatic searches are performed via the UI or by Readarr", + conflicts_with = "enable_automatic_search" + )] + disable_automatic_search: bool, + #[arg( + long, + help = "Indicate to Readarr that this indexer should be used when an interactive search is used", + conflicts_with = "disable_interactive_search" + )] + enable_interactive_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever an interactive search is performed", + conflicts_with = "enable_interactive_search" + )] + disable_interactive_search: bool, + #[arg(long, help = "The URL of the indexer")] + url: Option, + #[arg(long, help = "The API key used to access the indexer's API")] + api_key: Option, + #[arg( + long, + help = "The ratio a torrent should reach before stopping; Empty uses the download client's default. Ratio should be at least 1.0 and follow the indexer's rules" + )] + seed_ratio: Option, + #[arg( + long, + help = "Only use this indexer for authors with at least one matching tag ID. Leave blank to use with all authors.", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg( + long, + help = "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Readarr will still use all enabled indexers for RSS Sync and Searching" + )] + priority: Option, + #[arg(long, help = "Clear all tags on this indexer", conflicts_with = "tag")] + clear_tags: bool, + }, } impl From for Command { @@ -138,6 +230,48 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrEditCommand> for ReadarrEditComman .await?; "Author updated".to_owned() } + ReadarrEditCommand::Indexer { + indexer_id, + name, + enable_rss, + disable_rss, + enable_automatic_search, + disable_automatic_search, + enable_interactive_search, + disable_interactive_search, + url, + api_key, + seed_ratio, + tag, + priority, + clear_tags, + } => { + let rss_value = mutex_flags_or_option(enable_rss, disable_rss); + let automatic_search_value = + mutex_flags_or_option(enable_automatic_search, disable_automatic_search); + let interactive_search_value = + mutex_flags_or_option(enable_interactive_search, disable_interactive_search); + let edit_indexer_params = EditIndexerParams { + indexer_id, + name, + enable_rss: rss_value, + enable_automatic_search: automatic_search_value, + enable_interactive_search: interactive_search_value, + url, + api_key, + seed_ratio, + tags: tag, + tag_input_string: None, + priority, + clear_tags, + }; + + self + .network + .handle_network_event(ReadarrEvent::EditIndexer(edit_indexer_params).into()) + .await?; + "Indexer updated".to_owned() + } }; Ok(result) diff --git a/src/cli/readarr/edit_command_handler_tests.rs b/src/cli/readarr/edit_command_handler_tests.rs index 80313dcb..3d15d004 100644 --- a/src/cli/readarr/edit_command_handler_tests.rs +++ b/src/cli/readarr/edit_command_handler_tests.rs @@ -272,6 +272,282 @@ mod tests { }; assert_eq!(edit_command, expected_args); } + + #[test] + fn test_edit_indexer_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "edit", "indexer"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_with_indexer_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_rss_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--enable-rss", + "--disable-rss", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_automatic_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--enable-automatic-search", + "--disable-automatic-search", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_interactive_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--enable-interactive-search", + "--disable-interactive-search", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--tag", + "1", + "--clear-tags", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_indexer_assert_argument_flags_require_args( + #[values("--name", "--url", "--api-key", "--seed-ratio", "--tag", "--priority")] flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[rstest] + fn test_edit_indexer_every_boolean_flag_satisfies_the_argument_group( + #[values( + "--enable-rss", + "--disable-rss", + "--enable-automatic-search", + "--disable-automatic-search", + "--enable-interactive-search", + "--disable-interactive-search", + "--clear-tags" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + flag, + ]); + + assert_ok!(&result); + } + + #[test] + fn test_edit_indexer_only_requires_at_least_one_argument_plus_indexer_id() { + let expected_args = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: None, + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--name", + "Test", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_indexer_tag_argument_is_repeatable() { + let expected_args = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: None, + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: Some(vec![2, 3]), + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--tag", + "2", + "--tag", + "3", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_indexer_all_arguments_defined() { + let expected_args = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: true, + disable_automatic_search: false, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![2, 3]), + priority: Some(25), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--name", + "Test", + "--enable-rss", + "--enable-automatic-search", + "--enable-interactive-search", + "--url", + "http://test.com", + "--api-key", + "testKey", + "--seed-ratio", + "1.2", + "--tag", + "2", + "--tag", + "3", + "--priority", + "25", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } } mod handler { @@ -287,6 +563,7 @@ mod tests { }; use crate::models::Serdeable; use crate::models::readarr_models::{EditAuthorParams, NewItemMonitorType, ReadarrSerdeable}; + use crate::models::servarr_models::EditIndexerParams; use crate::network::readarr_network::ReadarrEvent; use crate::{ app::App, @@ -476,5 +753,59 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_handle_edit_indexer_command() { + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: Some(true), + enable_automatic_search: Some(false), + enable_interactive_search: Some(true), + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tags: Some(vec![2, 3]), + tag_input_string: None, + priority: Some(25), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditIndexer(expected_edit_indexer_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_indexer_command = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: true, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![2, 3]), + priority: Some(25), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_indexer_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs index 3b9b8db5..d374a34f 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -1,14 +1,230 @@ -use crate::models::servarr_models::Indexer; +use crate::models::servarr_models::{EditIndexerParams, Indexer}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; -use anyhow::Result; -use log::info; +use anyhow::{Context, Result}; +use log::{debug, info}; +use serde_json::{Value, json}; #[cfg(test)] #[path = "readarr_indexers_network_tests.rs"] mod readarr_indexers_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn edit_readarr_indexer( + &mut self, + mut edit_indexer_params: EditIndexerParams, + ) -> Result<()> { + if let Some(tag_input_str) = edit_indexer_params.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + edit_indexer_params.tags = Some(tag_ids_vec); + } + let detail_event = ReadarrEvent::GetIndexers; + let event = ReadarrEvent::EditIndexer(EditIndexerParams::default()); + let id = edit_indexer_params.indexer_id; + info!("Updating Readarr indexer with ID: {id}"); + info!("Fetching indexer details for indexer with ID: {id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + response = detailed_indexer_body.to_string() + }) + .await?; + + info!("Constructing edit indexer body"); + + let mut detailed_indexer_body: Value = serde_json::from_str(&response)?; + + let ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) = { + let priority = detailed_indexer_body["priority"] + .as_i64() + .context("Failed to deserialize indexer 'priority' field")?; + let seed_ratio_field_option = detailed_indexer_body["fields"] + .as_array() + .context("Failed to get indexer 'fields' array")? + .iter() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + let name = edit_indexer_params.name.unwrap_or( + detailed_indexer_body["name"] + .as_str() + .context("Failed to deserialize indexer 'name' field")? + .to_owned(), + ); + let enable_rss = edit_indexer_params.enable_rss.unwrap_or( + detailed_indexer_body["enableRss"] + .as_bool() + .context("Failed to deserialize indexer 'enableRss' field")?, + ); + let enable_automatic_search = edit_indexer_params.enable_automatic_search.unwrap_or( + detailed_indexer_body["enableAutomaticSearch"] + .as_bool() + .context("Failed to deserialize indexer 'enableAutomaticSearch' field")?, + ); + let enable_interactive_search = edit_indexer_params.enable_interactive_search.unwrap_or( + detailed_indexer_body["enableInteractiveSearch"] + .as_bool() + .context("Failed to deserialize indexer 'enableInteractiveSearch' field")?, + ); + let url = edit_indexer_params.url.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .context("Failed to get indexer 'fields' array for baseUrl")? + .iter() + .find(|field| field["name"] == "baseUrl") + .context("Field 'baseUrl' was not found in the indexer fields array")? + .get("value") + .unwrap_or(&json!("")) + .as_str() + .context("Failed to deserialize indexer 'baseUrl' value")? + .to_owned(), + ); + let api_key = edit_indexer_params.api_key.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .context("Failed to get indexer 'fields' array for apiKey")? + .iter() + .find(|field| field["name"] == "apiKey") + .context("Field 'apiKey' was not found in the indexer fields array")? + .get("value") + .unwrap_or(&json!("")) + .as_str() + .context("Failed to deserialize indexer 'apiKey' value")? + .to_owned(), + ); + let seed_ratio = edit_indexer_params.seed_ratio.unwrap_or_else(|| { + if let Some(seed_ratio_field) = seed_ratio_field_option { + return seed_ratio_field + .get("value") + .unwrap_or(&json!("")) + .as_str() + .unwrap_or("") + .to_owned(); + } + + String::new() + }); + let tags = if edit_indexer_params.clear_tags { + vec![] + } else { + edit_indexer_params.tags.unwrap_or( + detailed_indexer_body["tags"] + .as_array() + .context("Failed to get indexer 'tags' array")? + .iter() + .map(|item| { + item + .as_i64() + .context("Failed to deserialize indexer tag ID") + }) + .collect::>>()?, + ) + }; + let priority = edit_indexer_params.priority.unwrap_or(priority); + + ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) + }; + + *detailed_indexer_body + .get_mut("name") + .context("Failed to get mutable reference to indexer 'name' field")? = json!(name); + *detailed_indexer_body + .get_mut("priority") + .context("Failed to get mutable reference to indexer 'priority' field")? = json!(priority); + *detailed_indexer_body + .get_mut("enableRss") + .context("Failed to get mutable reference to indexer 'enableRss' field")? = json!(enable_rss); + *detailed_indexer_body + .get_mut("enableAutomaticSearch") + .context("Failed to get mutable reference to indexer 'enableAutomaticSearch' field")? = + json!(enable_automatic_search); + *detailed_indexer_body + .get_mut("enableInteractiveSearch") + .context("Failed to get mutable reference to indexer 'enableInteractiveSearch' field")? = + json!(enable_interactive_search); + *detailed_indexer_body + .get_mut("fields") + .and_then(|f| f.as_array_mut()) + .context("Failed to get mutable reference to indexer 'fields' array")? + .iter_mut() + .find(|field| field["name"] == "baseUrl") + .context("Failed to find 'baseUrl' field in indexer fields array")? + .get_mut("value") + .context("Failed to get mutable reference to 'baseUrl' value")? = json!(url); + *detailed_indexer_body + .get_mut("fields") + .and_then(|f| f.as_array_mut()) + .context("Failed to get mutable reference to indexer 'fields' array for apiKey")? + .iter_mut() + .find(|field| field["name"] == "apiKey") + .context("Failed to find 'apiKey' field in indexer fields array")? + .get_mut("value") + .context("Failed to get mutable reference to 'apiKey' value")? = json!(api_key); + *detailed_indexer_body + .get_mut("tags") + .context("Failed to get mutable reference to indexer 'tags' field")? = json!(tags); + let seed_ratio_field_option = detailed_indexer_body + .get_mut("fields") + .and_then(|f| f.as_array_mut()) + .context("Failed to get mutable reference to indexer 'fields' array for seed ratio")? + .iter_mut() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field + .as_object_mut() + .context("Failed to get mutable reference to 'seedCriteria.seedRatio' object")? + .insert("value".to_string(), json!(seed_ratio)); + } + + debug!("Edit indexer body: {detailed_indexer_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_indexer_body), + Some(format!("/{id}")), + Some("forceSave=true".to_owned()), + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_indexers( &mut self, ) -> Result> { diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs index 18f7016a..e09db0f3 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -1,12 +1,15 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::ReadarrSerdeable; - use crate::models::servarr_models::Indexer; + use crate::models::servarr_models::{EditIndexerParams, Indexer}; + use crate::network::NetworkResource; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ INDEXER_JSON, indexer, stale_indexer, }; + use bimap::BiMap; + use mockito::Matcher; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -78,4 +81,313 @@ mod tests { stale_indexers ); } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event() { + let mut expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + *expected_body.get_mut("name").unwrap() = json!("Test Update"); + *expected_body.get_mut("priority").unwrap() = json!(3); + *expected_body.get_mut("enableRss").unwrap() = json!(false); + *expected_body.get_mut("enableAutomaticSearch").unwrap() = json!(false); + *expected_body.get_mut("enableInteractiveSearch").unwrap() = json!(false); + *expected_body["fields"][0].get_mut("value").unwrap() = json!("https://localhost:9696/8/"); + *expected_body["fields"][1].get_mut("value").unwrap() = json!("test1234"); + *expected_body["fields"][2].get_mut("value").unwrap() = json!("1.3"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/8/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(3), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).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.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_defaults_to_previous_values() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_does_not_overwrite_tags_vec_if_tag_input_string_is_none() + { + let mut expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([2, 3]); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + tags: Some(vec![2, 3]), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_clears_tags_when_clear_tags_is_true() { + let mut expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + clear_tags: true, + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_absent_in_details() + { + let mut details_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + details_body["fields"].as_array_mut().unwrap().pop(); + let expected_body = details_body.clone(); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + seed_ratio: Some("1.3".to_owned()), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(details_body) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_populates_the_seed_ratio_value_when_it_is_missing_in_details() + { + let mut details_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + details_body["fields"][2] + .as_object_mut() + .unwrap() + .remove("value"); + let mut expected_body = details_body.clone(); + expected_body["fields"][2] + .as_object_mut() + .unwrap() + .insert("value".to_owned(), json!("1.3")); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + seed_ratio: Some("1.3".to_owned()), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(details_body) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_failure() { + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(500) + .match_header("X-Api-Key", "test1234") + .with_body(INDEXER_JSON) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 91ce827a..bb6d179f 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -7,7 +7,9 @@ use crate::models::readarr_models::{ AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, ReadarrTaskName, }; -use crate::models::servarr_models::{MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag}; +use crate::models::servarr_models::{ + EditIndexerParams, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, +}; use crate::network::{Network, RequestMethod}; mod blocklist; @@ -41,6 +43,7 @@ pub enum ReadarrEvent { DeleteTag(i64), DownloadRelease(ReleaseDownloadBody), EditAuthor(EditAuthorParams), + EditIndexer(EditIndexerParams), GetAuthorDetails(i64), GetAuthorHistory(i64), GetAuthorReleases(i64), @@ -112,7 +115,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetHistory(_) => "/history", ReadarrEvent::GetAuthorHistory(_) | ReadarrEvent::GetBookHistory(_, _) => "/history/author", ReadarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", - ReadarrEvent::GetIndexers => "/indexer", + ReadarrEvent::EditIndexer(_) | ReadarrEvent::GetIndexers => "/indexer", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -189,6 +192,10 @@ impl Network<'_, '_> { .edit_author(edit_author_params) .await .map(ReadarrSerdeable::from), + ReadarrEvent::EditIndexer(edit_indexer_params) => self + .edit_readarr_indexer(edit_indexer_params) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetAuthorDetails(author_id) => self .get_author_details(author_id) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index b95342b5..620d4abd 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -159,9 +159,33 @@ pub mod test_utils { "configContract": "TorznabSettings", "infoLink": "https://wiki.servarr.com/readarr/supported#torznab", "fields": [ - { "name": "baseUrl", "value": "https://test.com" }, - { "name": "apiKey", "value": "" }, - { "name": "seedCriteria.seedRatio", "value": "1.2" } + { + "order": 0, + "name": "baseUrl", + "label": "URL", + "value": "https://test.com", + "type": "textbox", + "advanced": false, + "isFloat": false + }, + { + "order": 2, + "name": "apiKey", + "label": "API Key", + "value": "", + "type": "textbox", + "advanced": false, + "isFloat": false + }, + { + "order": 7, + "name": "seedCriteria.seedRatio", + "label": "Seed Ratio", + "value": "1.2", + "type": "number", + "advanced": false, + "isFloat": true + } ], "tags": [1], "id": 8 diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 16ef7bad..3142fcef 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -4,7 +4,9 @@ mod tests { use crate::models::readarr_models::{ AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, }; - use crate::models::servarr_models::{MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag}; + use crate::models::servarr_models::{ + EditIndexerParams, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, + }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; use bimap::BiMap; @@ -47,6 +49,7 @@ mod tests { #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] #[case(ReadarrEvent::GetBookHistory(1, 2), "/history/author")] #[case(ReadarrEvent::MarkHistoryItemAsFailed(1), "/history/failed")] + #[case(ReadarrEvent::EditIndexer(EditIndexerParams::default()), "/indexer")] #[case(ReadarrEvent::GetIndexers, "/indexer")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] From aea7fd2607f304c336c2f66113a4c411de84f6b0 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 1 Sep 2026 17:16:23 -0600 Subject: [PATCH 059/170] feat(readarr): delete an indexer and expose the command on the CLI --- src/cli/readarr/delete_command_handler.rs | 12 ++++ .../readarr/delete_command_handler_tests.rs | 72 +++++++++++++++++++ src/network/readarr_network/indexers/mod.rs | 22 ++++++ .../readarr_indexers_network_tests.rs | 35 +++++++++ src/network/readarr_network/mod.rs | 9 ++- .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs index cf280076..f0edd0c5 100644 --- a/src/cli/readarr/delete_command_handler.rs +++ b/src/cli/readarr/delete_command_handler.rs @@ -55,6 +55,11 @@ pub enum ReadarrDeleteCommand { #[arg(long, help = "The ID of the download to delete", required = true)] download_id: i64, }, + #[command(about = "Delete the indexer with the given ID")] + Indexer { + #[arg(long, help = "The ID of the indexer to delete", required = true)] + indexer_id: i64, + }, #[command(about = "Delete the root folder with the given ID")] RootFolder { #[arg(long, help = "The ID of the root folder to delete", required = true)] @@ -149,6 +154,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> .await?; serde_json::to_string_pretty(&resp)? } + ReadarrDeleteCommand::Indexer { indexer_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteIndexer(indexer_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrDeleteCommand::RootFolder { root_folder_id } => { let resp = self .network diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs index 5cb6b2b2..0b1bbe50 100644 --- a/src/cli/readarr/delete_command_handler_tests.rs +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -326,6 +326,55 @@ mod tests { assert_eq!(delete_command, expected_args); } + #[test] + fn test_delete_indexer_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "indexer"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_indexer_indexer_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "indexer", + "--indexer-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_indexer_success() { + let expected_args = ReadarrDeleteCommand::Indexer { indexer_id: 8 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "indexer", + "--indexer-id", + "8", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + #[test] fn test_delete_root_folder_requires_arguments() { let result = @@ -580,6 +629,29 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_delete_indexer_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteIndexer(8).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_indexer_command = ReadarrDeleteCommand::Indexer { indexer_id: 8 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_indexer_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_delete_root_folder_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs index d374a34f..165d8525 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -10,6 +10,28 @@ use serde_json::{Value, json}; mod readarr_indexers_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn delete_readarr_indexer( + &mut self, + indexer_id: i64, + ) -> Result<()> { + info!("Deleting Readarr indexer with id: {indexer_id}"); + let event = ReadarrEvent::DeleteIndexer(indexer_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{indexer_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn edit_readarr_indexer( &mut self, mut edit_indexer_params: EditIndexerParams, diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs index e09db0f3..80bcacdf 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -13,6 +13,41 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::{Value, json}; + #[tokio::test] + async fn test_handle_delete_readarr_indexer_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/8") + .build_for(ReadarrEvent::DeleteIndexer(8)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteIndexer(8)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_indexer_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/8") + .status(500) + .build_for(ReadarrEvent::DeleteIndexer(8)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteIndexer(8)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_readarr_indexers_event() { let indexers_response_json = json!([serde_json::from_str::(INDEXER_JSON).unwrap()]); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index bb6d179f..06cac640 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -39,6 +39,7 @@ pub enum ReadarrEvent { DeleteBook(DeleteParams), DeleteBookFile(i64), DeleteDownload(i64), + DeleteIndexer(i64), DeleteRootFolder(i64), DeleteTag(i64), DownloadRelease(ReleaseDownloadBody), @@ -115,7 +116,9 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetHistory(_) => "/history", ReadarrEvent::GetAuthorHistory(_) | ReadarrEvent::GetBookHistory(_, _) => "/history/author", ReadarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", - ReadarrEvent::EditIndexer(_) | ReadarrEvent::GetIndexers => "/indexer", + ReadarrEvent::DeleteIndexer(_) | ReadarrEvent::EditIndexer(_) | ReadarrEvent::GetIndexers => { + "/indexer" + } ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -176,6 +179,10 @@ impl Network<'_, '_> { .delete_readarr_download(download_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteIndexer(indexer_id) => self + .delete_readarr_indexer(indexer_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::DeleteRootFolder(root_folder_id) => self .delete_readarr_root_folder(root_folder_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 3142fcef..555f9ca1 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -49,6 +49,7 @@ mod tests { #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] #[case(ReadarrEvent::GetBookHistory(1, 2), "/history/author")] #[case(ReadarrEvent::MarkHistoryItemAsFailed(1), "/history/failed")] + #[case(ReadarrEvent::DeleteIndexer(1), "/indexer")] #[case(ReadarrEvent::EditIndexer(EditIndexerParams::default()), "/indexer")] #[case(ReadarrEvent::GetIndexers, "/indexer")] #[case(ReadarrEvent::GetLogs(500), "/log")] From f4fd8516a6636509f84b844c9d03ef4e9ef5d3f9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 13:39:29 -0600 Subject: [PATCH 060/170] feat(readarr): test an indexer and expose the command on the CLI Fetch the indexer's full details, then post them back verbatim to the test endpoint. The payload round-trips through serde_json::Value so the fields Indexer does not model, such as infoLink and the per-field label and type metadata, survive the round trip intact. A successful test returns an empty JSON object and clears indexer_test_errors; a failure returns an array whose first entry carries the error message. The test endpoint reports failures with a non-2xx status, so the request ignores the status code and parses the body either way. --- src/cli/readarr/mod.rs | 14 ++ src/cli/readarr/readarr_command_tests.rs | 49 ++++++ src/network/readarr_network/indexers/mod.rs | 52 +++++++ .../readarr_indexers_network_tests.rs | 145 ++++++++++++++++++ src/network/readarr_network/mod.rs | 6 + .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 267 insertions(+) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 774dc62c..566a3197 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -110,6 +110,13 @@ pub enum ReadarrCommand { )] task_name: ReadarrTaskName, }, + #[command( + about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'" + )] + TestIndexer { + #[arg(long, help = "The ID of the indexer to test", required = true)] + indexer_id: i64, + }, #[command( about = "Toggle monitoring for the specified author corresponding to the given author ID" )] @@ -240,6 +247,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .await?; serde_json::to_string_pretty(&resp)? } + ReadarrCommand::TestIndexer { indexer_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::TestIndexer(indexer_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrCommand::ToggleAuthorMonitoring { author_id } => { let resp = self .network diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index c61d1cd2..e7ad9cf7 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -151,6 +151,30 @@ mod tests { assert_ok!(&result); } + #[test] + fn test_test_indexer_requires_indexer_id() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "test-indexer"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_test_indexer_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "test-indexer", + "--indexer-id", + "8", + ]); + + assert_ok!(&result); + } + #[test] fn test_toggle_author_monitoring_requires_author_id() { let result = @@ -346,6 +370,31 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_test_indexer_command() { + let expected_indexer_id = 8; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::TestIndexer(expected_indexer_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let test_indexer_command = ReadarrCommand::TestIndexer { indexer_id: 8 }; + + let result = ReadarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_toggle_author_monitoring_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs index 165d8525..9f6ff7e4 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -263,4 +263,56 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn test_readarr_indexer( + &mut self, + indexer_id: i64, + ) -> Result { + let detail_event = ReadarrEvent::GetIndexers; + let event = ReadarrEvent::TestIndexer(indexer_id); + info!("Testing Readarr indexer with ID: {indexer_id}"); + + info!("Fetching indexer details for indexer with ID: {indexer_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{indexer_id}")), + None, + ) + .await; + + let mut test_body: Value = Value::default(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + test_body = detailed_indexer_body; + }) + .await?; + + info!("Testing indexer"); + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::(request_props, |test_results, mut app| { + if test_results.as_object().is_none() { + let error_message = test_results + .as_array() + .and_then(|arr| arr.first()) + .and_then(|item| item.get("errorMessage")) + .map(|msg| msg.to_string()) + .unwrap_or_else(|| "Unknown indexer test error".to_string()); + app.data.readarr_data.indexer_test_errors = Some(error_message); + } else { + app.data.readarr_data.indexer_test_errors = Some(String::new()); + }; + }) + .await + } } diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs index 80bcacdf..999da1d8 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -425,4 +425,149 @@ mod tests { async_edit_server.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v1{}", ReadarrEvent::TestIndexer(8).resource()).as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .with_body("{}") + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!(&app.data.readarr_data.indexer_test_errors, &String::new()); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event_captures_validation_errors() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v1{}", ReadarrEvent::TestIndexer(8).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .with_body(json!([{ "errorMessage": "Unable to connect to indexer" }]).to_string()) + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_test_errors, + &"\"Unable to connect to indexer\"".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event_falls_back_to_a_generic_error_message() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v1{}", ReadarrEvent::TestIndexer(8).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .with_body("[]") + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_test_errors, + &"Unknown indexer test error".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event_failure() { + let (async_details_server, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .status(500) + .build_for(ReadarrEvent::GetIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_test_errors, + &"stale error".to_owned() + ); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 06cac640..5e4ee3f5 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -75,6 +75,7 @@ pub enum ReadarrEvent { MarkHistoryItemAsFailed(i64), SearchNewAuthor(String), StartTask(ReadarrTaskName), + TestIndexer(i64), ToggleAuthorMonitoring(i64), ToggleBookMonitoring(i64), TriggerAutomaticAuthorSearch(i64), @@ -119,6 +120,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::DeleteIndexer(_) | ReadarrEvent::EditIndexer(_) | ReadarrEvent::GetIndexers => { "/indexer" } + ReadarrEvent::TestIndexer(_) => "/indexer/test", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -306,6 +308,10 @@ impl Network<'_, '_> { .start_readarr_task(task_name) .await .map(ReadarrSerdeable::from), + ReadarrEvent::TestIndexer(indexer_id) => self + .test_readarr_indexer(indexer_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::ToggleAuthorMonitoring(author_id) => self .toggle_author_monitoring(author_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 555f9ca1..3959166e 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -52,6 +52,7 @@ mod tests { #[case(ReadarrEvent::DeleteIndexer(1), "/indexer")] #[case(ReadarrEvent::EditIndexer(EditIndexerParams::default()), "/indexer")] #[case(ReadarrEvent::GetIndexers, "/indexer")] + #[case(ReadarrEvent::TestIndexer(8), "/indexer/test")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] From cd5ef8e093344512ce6d6c4a896d3e4cfcbd496b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 13:46:36 -0600 Subject: [PATCH 061/170] feat(readarr): test all indexers and expose the command on the CLI Results come back keyed only by indexer id, so each one is joined against the cached indexer list to recover a display name, and its validation failures are flattened into a single line for the results table. The endpoint reports failures with a non-2xx status, so the request ignores the status code and parses the body either way. When the request itself fails the results table is replaced with an empty one rather than being left showing the previous run. --- src/cli/readarr/mod.rs | 10 +++ src/cli/readarr/readarr_command_tests.rs | 31 ++++++- src/network/readarr_network/indexers/mod.rs | 65 ++++++++++++++- .../readarr_indexers_network_tests.rs | 80 ++++++++++++++++++- src/network/readarr_network/mod.rs | 6 ++ .../readarr_network_test_utils.rs | 58 ++++++++++++++ .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 245 insertions(+), 6 deletions(-) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 566a3197..76eb484f 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -110,6 +110,8 @@ pub enum ReadarrCommand { )] task_name: ReadarrTaskName, }, + #[command(about = "Test all Readarr indexers")] + TestAllIndexers, #[command( about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'" )] @@ -247,6 +249,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .await?; serde_json::to_string_pretty(&resp)? } + ReadarrCommand::TestAllIndexers => { + println!("Testing all Readarr indexers. This may take a minute..."); + let resp = self + .network + .handle_network_event(ReadarrEvent::TestAllIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrCommand::TestIndexer { indexer_id } => { let resp = self .network diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index e7ad9cf7..21a25396 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -4,6 +4,7 @@ mod tests { use clap::CommandFactory; use clap::error::ErrorKind; use pretty_assertions::assert_eq; + use rstest::rstest; use crate::Cli; @@ -212,9 +213,11 @@ mod tests { ); } - #[test] - fn test_clear_blocklist_has_no_arg_requirements() { - let result = Cli::command().try_get_matches_from(["managarr", "readarr", "clear-blocklist"]); + #[rstest] + fn test_commands_that_have_no_arg_requirements( + #[values("clear-blocklist", "test-all-indexers")] subcommand: &str, + ) { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", subcommand]); assert_ok!(&result); } @@ -395,6 +398,28 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_test_all_indexers_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::TestAllIndexers.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let test_all_indexers_command = ReadarrCommand::TestAllIndexers; + + let result = ReadarrCliHandler::with(&app_arc, test_all_indexers_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_toggle_author_monitoring_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs index 9f6ff7e4..98afff73 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -1,4 +1,6 @@ -use crate::models::servarr_models::{EditIndexerParams, Indexer}; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; +use crate::models::stateful_table::StatefulTable; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::{Context, Result}; @@ -315,4 +317,65 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn test_all_readarr_indexers( + &mut self, + ) -> Result> { + info!("Testing all Readarr indexers"); + let event = ReadarrEvent::TestAllIndexers; + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, None, None, None) + .await; + request_props.ignore_status_code = true; + + let result = self + .handle_request::<(), Vec>(request_props, |test_results, mut app| { + let mut test_all_indexer_results = StatefulTable::default(); + let indexers = app.data.readarr_data.indexers.items.clone(); + let modal_test_results = test_results + .iter() + .map(|result| { + let name = indexers + .iter() + .filter(|&indexer| indexer.id == result.id) + .map(|indexer| indexer.name.clone()) + .nth(0) + .unwrap_or_default(); + let validation_failures = result + .validation_failures + .iter() + .map(|failure| { + format!( + "Failure for field '{}': {}", + failure.property_name, failure.error_message + ) + }) + .collect::>() + .join(", "); + + IndexerTestResultModalItem { + name: name.unwrap_or_default(), + is_valid: result.is_valid, + validation_failures: validation_failures.into(), + } + }) + .collect(); + test_all_indexer_results.set_items(modal_test_results); + app.data.readarr_data.indexer_test_all_results = Some(test_all_indexer_results); + }) + .await; + + if result.is_err() { + self + .app + .lock() + .await + .data + .readarr_data + .indexer_test_all_results = Some(StatefulTable::default()); + } + + result + } } diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs index 999da1d8..153c4ac0 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -1,12 +1,14 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::ReadarrSerdeable; - use crate::models::servarr_models::{EditIndexerParams, Indexer}; + use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; + use crate::models::stateful_table::StatefulTable; use crate::network::NetworkResource; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - INDEXER_JSON, indexer, stale_indexer, + INDEXER_JSON, INDEXER_TEST_RESULTS_JSON, indexer, indexer_test_results, stale_indexer, + tested_indexers, }; use bimap::BiMap; use mockito::Matcher; @@ -570,4 +572,78 @@ mod tests { &"stale error".to_owned() ); } + + #[tokio::test] + async fn test_handle_test_all_readarr_indexers_event() { + let response_json: Value = serde_json::from_str(INDEXER_TEST_RESULTS_JSON).unwrap(); + let expected_results: Vec = + serde_json::from_str(INDEXER_TEST_RESULTS_JSON).unwrap(); + let (async_server, app, _server) = MockServarrApi::post() + .returns(response_json) + .status(400) + .build_for(ReadarrEvent::TestAllIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexers.set_items(tested_indexers()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestAllIndexers) + .await; + + async_server.assert_async().await; + let ReadarrSerdeable::IndexerTestResults(results) = assert_ok!(result) else { + panic!("Expected IndexerTestResults") + }; + assert_eq!(results, expected_results); + let app = app.lock().await; + assert_eq!( + app + .data + .readarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items, + indexer_test_results() + ); + } + + #[tokio::test] + async fn test_handle_test_all_readarr_indexers_event_failure() { + let (async_server, app, _server) = MockServarrApi::post() + .returns(json!({ "message": "Internal Server Error" })) + .status(500) + .build_for(ReadarrEvent::TestAllIndexers) + .await; + { + let mut stale_test_all_results = StatefulTable::default(); + stale_test_all_results.set_items(indexer_test_results()); + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexers.set_items(tested_indexers()); + app.data.readarr_data.indexer_test_all_results = Some(stale_test_all_results); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestAllIndexers) + .await; + + async_server.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_is_empty!( + app + .data + .readarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items + ); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 5e4ee3f5..a16ff907 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -75,6 +75,7 @@ pub enum ReadarrEvent { MarkHistoryItemAsFailed(i64), SearchNewAuthor(String), StartTask(ReadarrTaskName), + TestAllIndexers, TestIndexer(i64), ToggleAuthorMonitoring(i64), ToggleBookMonitoring(i64), @@ -121,6 +122,7 @@ impl NetworkResource for ReadarrEvent { "/indexer" } ReadarrEvent::TestIndexer(_) => "/indexer/test", + ReadarrEvent::TestAllIndexers => "/indexer/testall", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -308,6 +310,10 @@ impl Network<'_, '_> { .start_readarr_task(task_name) .await .map(ReadarrSerdeable::from), + ReadarrEvent::TestAllIndexers => self + .test_all_readarr_indexers() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::TestIndexer(indexer_id) => self .test_readarr_indexer(indexer_id) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 620d4abd..23537302 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -1,12 +1,14 @@ #[cfg(test)] #[allow(dead_code)] pub mod test_utils { + use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{ AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, BlocklistItem, Book, BookFile, DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType, NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease, }; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_models::{Indexer, IndexerField, Quality, QualityWrapper, RootFolder}; use chrono::DateTime; use serde_json::{Number, json}; @@ -463,4 +465,60 @@ pub mod test_utils { tags: vec![Number::from(1)], } } + + pub const INDEXER_TEST_RESULTS_JSON: &str = r#"[ + { + "id": 8, + "isValid": true, + "validationFailures": [] + }, + { + "id": 14, + "isValid": false, + "validationFailures": [ + { + "propertyName": "baseUrl", + "errorMessage": "Unable to connect to indexer", + "severity": "error" + }, + { + "propertyName": "apiKey", + "errorMessage": "Invalid API key", + "severity": "error" + } + ] + } + ]"#; + + pub fn tested_indexers() -> Vec { + vec![ + Indexer { + id: 8, + name: Some("Test Indexer".to_owned()), + ..Indexer::default() + }, + Indexer { + id: 14, + name: Some("Other Indexer".to_owned()), + ..Indexer::default() + }, + ] + } + + pub fn indexer_test_results() -> Vec { + vec![ + IndexerTestResultModalItem { + name: "Test Indexer".to_owned(), + is_valid: true, + validation_failures: HorizontallyScrollableText::default(), + }, + IndexerTestResultModalItem { + name: "Other Indexer".to_owned(), + is_valid: false, + validation_failures: + "Failure for field 'baseUrl': Unable to connect to indexer, Failure for field 'apiKey': Invalid API key" + .into(), + }, + ] + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 3959166e..31db1641 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -53,6 +53,7 @@ mod tests { #[case(ReadarrEvent::EditIndexer(EditIndexerParams::default()), "/indexer")] #[case(ReadarrEvent::GetIndexers, "/indexer")] #[case(ReadarrEvent::TestIndexer(8), "/indexer/test")] + #[case(ReadarrEvent::TestAllIndexers, "/indexer/testall")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] From 0867ae918e50f6f3b896bf9750845ee9c39f19ae Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 13:52:26 -0600 Subject: [PATCH 062/170] feat(readarr): fetch the shared indexer settings and expose them on the CLI The fetched settings are only written into app state when nothing is already held there, so a refresh landing while the user has the settings prompt open cannot overwrite edits in progress. The fixtures give every field a distinct value. Settings whose fields all share one value cannot detect two of them being mapped to each other's keys. --- src/cli/readarr/get_command_handler.rs | 9 +++ src/cli/readarr/get_command_handler_tests.rs | 36 ++++++++++ src/network/readarr_network/indexers/mod.rs | 25 ++++++- .../readarr_indexers_network_tests.rs | 72 ++++++++++++++++++- src/network/readarr_network/mod.rs | 6 ++ .../readarr_network_test_utils.rs | 32 ++++++++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 177 insertions(+), 4 deletions(-) diff --git a/src/cli/readarr/get_command_handler.rs b/src/cli/readarr/get_command_handler.rs index 65b8639b..225ce3a3 100644 --- a/src/cli/readarr/get_command_handler.rs +++ b/src/cli/readarr/get_command_handler.rs @@ -18,6 +18,8 @@ mod get_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrGetCommand { + #[command(about = "Get the shared settings for all indexers")] + AllIndexerSettings, #[command(about = "Get detailed information for the author with the given ID")] AuthorDetails { #[arg( @@ -71,6 +73,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrGetCommand> for ReadarrGetCommandH async fn handle(self) -> Result { let result = match self.command { + ReadarrGetCommand::AllIndexerSettings => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetAllIndexerSettings.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrGetCommand::AuthorDetails { author_id } => { let resp = self .network diff --git a/src/cli/readarr/get_command_handler_tests.rs b/src/cli/readarr/get_command_handler_tests.rs index cc590648..6ec4733b 100644 --- a/src/cli/readarr/get_command_handler_tests.rs +++ b/src/cli/readarr/get_command_handler_tests.rs @@ -24,6 +24,14 @@ mod tests { use super::*; + #[test] + fn test_all_indexer_settings_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "get", "all-indexer-settings"]); + + assert_ok!(&result); + } + #[test] fn test_author_details_requires_author_id() { let result = @@ -103,6 +111,34 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; + #[tokio::test] + async fn test_handle_get_all_indexer_settings_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_all_indexer_settings_command = ReadarrGetCommand::AllIndexerSettings; + + let result = ReadarrGetCommandHandler::with( + &app_arc, + get_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_get_author_details_command() { let expected_author_id = 1; diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs index 98afff73..db0bde54 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -1,5 +1,7 @@ use crate::models::servarr_data::modals::IndexerTestResultModalItem; -use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; +use crate::models::servarr_models::{ + EditIndexerParams, Indexer, IndexerSettings, IndexerTestResult, +}; use crate::models::stateful_table::StatefulTable; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; @@ -34,6 +36,27 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_all_readarr_indexer_settings( + &mut self, + ) -> Result { + info!("Fetching Readarr indexer settings"); + let event = ReadarrEvent::GetAllIndexerSettings; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), IndexerSettings>(request_props, |indexer_settings, mut app| { + if app.data.readarr_data.indexer_settings.is_none() { + app.data.readarr_data.indexer_settings = Some(indexer_settings); + } else { + debug!("Indexer Settings are being modified. Ignoring update..."); + } + }) + .await + } + pub(in crate::network::readarr_network) async fn edit_readarr_indexer( &mut self, mut edit_indexer_params: EditIndexerParams, diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs index 153c4ac0..c9f5920d 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -7,8 +7,8 @@ mod tests { use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - INDEXER_JSON, INDEXER_TEST_RESULTS_JSON, indexer, indexer_test_results, stale_indexer, - tested_indexers, + INDEXER_JSON, INDEXER_SETTINGS_JSON, INDEXER_TEST_RESULTS_JSON, indexer, indexer_settings, + indexer_test_results, stale_indexer, stale_indexer_settings, tested_indexers, }; use bimap::BiMap; use mockito::Matcher; @@ -646,4 +646,72 @@ mod tests { .items ); } + + #[tokio::test] + async fn test_handle_get_all_readarr_indexer_settings_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .build_for(ReadarrEvent::GetAllIndexerSettings) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAllIndexerSettings) + .await; + + mock.assert_async().await; + let ReadarrSerdeable::IndexerSettings(settings) = assert_ok!(result) else { + panic!("Expected IndexerSettings") + }; + assert_eq!(settings, indexer_settings()); + let app = app.lock().await; + assert_some_eq_x!(&app.data.readarr_data.indexer_settings, &indexer_settings()); + } + + #[tokio::test] + async fn test_handle_get_all_readarr_indexer_settings_event_does_not_overwrite_pending_edits() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .build_for(ReadarrEvent::GetAllIndexerSettings) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_settings = Some(stale_indexer_settings()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAllIndexerSettings) + .await; + + mock.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_settings, + &stale_indexer_settings() + ); + } + + #[tokio::test] + async fn test_handle_get_all_readarr_indexer_settings_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .status(500) + .build_for(ReadarrEvent::GetAllIndexerSettings) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAllIndexerSettings) + .await; + + mock.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_none!(&app.data.readarr_data.indexer_settings); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index a16ff907..551dd219 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -45,6 +45,7 @@ pub enum ReadarrEvent { DownloadRelease(ReleaseDownloadBody), EditAuthor(EditAuthorParams), EditIndexer(EditIndexerParams), + GetAllIndexerSettings, GetAuthorDetails(i64), GetAuthorHistory(i64), GetAuthorReleases(i64), @@ -112,6 +113,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::UpdateAndScanAuthor(_) | ReadarrEvent::UpdateDownloads => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", + ReadarrEvent::GetAllIndexerSettings => "/config/indexer", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::GetBookEditions(_) => "/edition", ReadarrEvent::HealthCheck => "/health", @@ -207,6 +209,10 @@ impl Network<'_, '_> { .edit_readarr_indexer(edit_indexer_params) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetAllIndexerSettings => self + .get_all_readarr_indexer_settings() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetAuthorDetails(author_id) => self .get_author_details(author_id) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 23537302..60086768 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -9,7 +9,9 @@ pub mod test_utils { ReadarrRelease, }; use crate::models::servarr_data::modals::IndexerTestResultModalItem; - use crate::models::servarr_models::{Indexer, IndexerField, Quality, QualityWrapper, RootFolder}; + use crate::models::servarr_models::{ + Indexer, IndexerField, IndexerSettings, Quality, QualityWrapper, RootFolder, + }; use chrono::DateTime; use serde_json::{Number, json}; @@ -521,4 +523,32 @@ pub mod test_utils { }, ] } + + pub const INDEXER_SETTINGS_JSON: &str = r#"{ + "id": 4, + "minimumAge": 17, + "retention": 43, + "maximumSize": 26500, + "rssSyncInterval": 35 + }"#; + + pub fn indexer_settings() -> IndexerSettings { + IndexerSettings { + id: 4, + minimum_age: 17, + retention: 43, + maximum_size: 26500, + rss_sync_interval: 35, + } + } + + pub fn stale_indexer_settings() -> IndexerSettings { + IndexerSettings { + id: 9, + minimum_age: 22, + retention: 58, + maximum_size: 31200, + rss_sync_interval: 90, + } + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 31db1641..2b6bfa1b 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -42,6 +42,7 @@ mod tests { #[case(ReadarrEvent::UpdateDownloads, "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] + #[case(ReadarrEvent::GetAllIndexerSettings, "/config/indexer")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::GetBookEditions(1), "/edition")] #[case(ReadarrEvent::HealthCheck, "/health")] From dbd84d929d1b1d845454e29ce36fe90a2d8c82d6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 15:37:25 -0600 Subject: [PATCH 063/170] feat(readarr): edit the shared indexer settings and expose the command on the CLI --- src/cli/readarr/edit_command_handler.rs | 67 ++++- src/cli/readarr/edit_command_handler_tests.rs | 246 +++++++++++++++++- src/network/readarr_network/indexers/mod.rs | 17 ++ .../readarr_indexers_network_tests.rs | 36 +++ src/network/readarr_network/mod.rs | 11 +- .../readarr_network/readarr_network_tests.rs | 6 +- 6 files changed, 378 insertions(+), 5 deletions(-) diff --git a/src/cli/readarr/edit_command_handler.rs b/src/cli/readarr/edit_command_handler.rs index d6cdb540..3af38b2f 100644 --- a/src/cli/readarr/edit_command_handler.rs +++ b/src/cli/readarr/edit_command_handler.rs @@ -5,7 +5,9 @@ use clap::{ArgAction, ArgGroup, Subcommand}; use tokio::sync::Mutex; use super::ReadarrCommand; -use crate::models::servarr_models::EditIndexerParams; +use crate::models::Serdeable; +use crate::models::readarr_models::ReadarrSerdeable; +use crate::models::servarr_models::{EditIndexerParams, IndexerSettings}; use crate::{ app::App, cli::{CliCommandHandler, Command, mutex_flags_or_option}, @@ -19,6 +21,40 @@ mod edit_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrEditCommand { + #[command( + about = "Edit the indexer settings that apply to all indexers", + group( + ArgGroup::new("edit_settings") + .args([ + "maximum_size", + "minimum_age", + "retention", + "rss_sync_interval", + ]).required(true) + .multiple(true)) + )] + AllIndexerSettings { + #[arg( + long, + help = "The maximum size for a release to be grabbed in MB. Set to zero to set to unlimited" + )] + maximum_size: Option, + #[arg( + long, + help = "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider." + )] + minimum_age: Option, + #[arg( + long, + help = "Usenet only: The retention time in days to retain releases. Set to zero to set for unlimited retention" + )] + retention: Option, + #[arg( + long, + help = "The RSS sync interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)" + )] + rss_sync_interval: Option, + }, #[command( about = "Edit preferences for the specified author", group( @@ -200,6 +236,35 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrEditCommand> for ReadarrEditComman async fn handle(self) -> Result { let result = match self.command { + ReadarrEditCommand::AllIndexerSettings { + maximum_size, + minimum_age, + retention, + rss_sync_interval, + } => { + if let Serdeable::Readarr(ReadarrSerdeable::IndexerSettings(previous_indexer_settings)) = + self + .network + .handle_network_event(ReadarrEvent::GetAllIndexerSettings.into()) + .await? + { + let params = IndexerSettings { + id: 1, + maximum_size: maximum_size.unwrap_or(previous_indexer_settings.maximum_size), + minimum_age: minimum_age.unwrap_or(previous_indexer_settings.minimum_age), + retention: retention.unwrap_or(previous_indexer_settings.retention), + rss_sync_interval: rss_sync_interval + .unwrap_or(previous_indexer_settings.rss_sync_interval), + }; + self + .network + .handle_network_event(ReadarrEvent::EditAllIndexerSettings(params).into()) + .await?; + "All indexer settings updated".to_owned() + } else { + String::new() + } + } ReadarrEditCommand::Author { author_id, enable_monitoring, diff --git a/src/cli/readarr/edit_command_handler_tests.rs b/src/cli/readarr/edit_command_handler_tests.rs index 3d15d004..34da2e65 100644 --- a/src/cli/readarr/edit_command_handler_tests.rs +++ b/src/cli/readarr/edit_command_handler_tests.rs @@ -36,6 +36,102 @@ mod tests { use pretty_assertions::assert_eq; use rstest::rstest; + #[test] + fn test_edit_all_indexer_settings_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_edit_all_indexer_settings_assert_argument_flags_require_args( + #[values( + "--maximum-size", + "--minimum-age", + "--retention", + "--rss-sync-interval" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_all_indexer_settings_only_requires_at_least_one_argument() { + let expected_args = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "26500", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_all_indexer_settings_all_arguments_defined() { + let expected_args = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "26500", + "--minimum-age", + "17", + "--retention", + "43", + "--rss-sync-interval", + "35", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + #[test] fn test_edit_author_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "edit", "author"]); @@ -554,6 +650,7 @@ mod tests { use std::sync::Arc; use mockall::predicate::eq; + use pretty_assertions::assert_str_eq; use serde_json::json; use tokio::sync::Mutex; @@ -563,13 +660,160 @@ mod tests { }; use crate::models::Serdeable; use crate::models::readarr_models::{EditAuthorParams, NewItemMonitorType, ReadarrSerdeable}; - use crate::models::servarr_models::EditIndexerParams; + use crate::models::servarr_models::{EditIndexerParams, IndexerSettings}; use crate::network::readarr_network::ReadarrEvent; use crate::{ app::App, network::{MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 26500, + minimum_age: 17, + retention: 43, + rss_sync_interval: 35, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings( + IndexerSettings { + id: 9, + maximum_size: 31200, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }; + + let result = ReadarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command_defaults_to_previous_values() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 26500, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings( + IndexerSettings { + id: 9, + maximum_size: 31200, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + + let result = ReadarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command_does_not_edit_on_unexpected_response() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }; + + let result = ReadarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_str_eq!(assert_ok!(result), String::new()); + } + #[tokio::test] async fn test_handle_edit_author_command() { let expected_edit_author_params = EditAuthorParams { diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs index db0bde54..b7413dc0 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -36,6 +36,23 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn edit_all_readarr_indexer_settings( + &mut self, + params: IndexerSettings, + ) -> Result { + info!("Updating Readarr indexer settings"); + let event = ReadarrEvent::EditAllIndexerSettings(IndexerSettings::default()); + debug!("Indexer settings body: {params:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Put, Some(params), None, None) + .await; + + self + .handle_request::(request_props, |_, _| {}) + .await + } + pub(in crate::network::readarr_network) async fn get_all_readarr_indexer_settings( &mut self, ) -> Result { diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs index c9f5920d..02dbcbf1 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -714,4 +714,40 @@ mod tests { let app = app.lock().await; assert_none!(&app.data.readarr_data.indexer_settings); } + + #[tokio::test] + async fn test_handle_edit_all_readarr_indexer_settings_event() { + let (mock, app, _server) = MockServarrApi::put() + .with_request_body(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .build_for(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + + mock.assert_async().await; + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_all_readarr_indexer_settings_event_failure() { + let (mock, app, _server) = MockServarrApi::put() + .with_request_body(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .returns(json!({ "message": "Internal Server Error" })) + .status(500) + .build_for(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 551dd219..48d1a6f5 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -8,7 +8,7 @@ use crate::models::readarr_models::{ ReadarrTaskName, }; use crate::models::servarr_models::{ - EditIndexerParams, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, }; use crate::network::{Network, RequestMethod}; @@ -43,6 +43,7 @@ pub enum ReadarrEvent { DeleteRootFolder(i64), DeleteTag(i64), DownloadRelease(ReleaseDownloadBody), + EditAllIndexerSettings(IndexerSettings), EditAuthor(EditAuthorParams), EditIndexer(EditIndexerParams), GetAllIndexerSettings, @@ -113,7 +114,9 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::UpdateAndScanAuthor(_) | ReadarrEvent::UpdateDownloads => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", - ReadarrEvent::GetAllIndexerSettings => "/config/indexer", + ReadarrEvent::GetAllIndexerSettings | ReadarrEvent::EditAllIndexerSettings(_) => { + "/config/indexer" + } ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::GetBookEditions(_) => "/edition", ReadarrEvent::HealthCheck => "/health", @@ -201,6 +204,10 @@ impl Network<'_, '_> { .download_readarr_release(release_download_body) .await .map(ReadarrSerdeable::from), + ReadarrEvent::EditAllIndexerSettings(params) => self + .edit_all_readarr_indexer_settings(params) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::EditAuthor(edit_author_params) => self .edit_author(edit_author_params) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 2b6bfa1b..1c0bf4a3 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -5,7 +5,7 @@ mod tests { AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, }; use crate::models::servarr_models::{ - EditIndexerParams, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; @@ -43,6 +43,10 @@ mod tests { #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetAllIndexerSettings, "/config/indexer")] + #[case( + ReadarrEvent::EditAllIndexerSettings(IndexerSettings::default()), + "/config/indexer" + )] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::GetBookEditions(1), "/edition")] #[case(ReadarrEvent::HealthCheck, "/health")] From d6deb6eea2addc7badd065a7ef9c65c86179fbc9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 16:32:06 -0600 Subject: [PATCH 064/170] feat(readarr): fetch data on tick for the active block Give dispatch_by_readarr_block a real match so each view requests what it needs, and refresh the shared metadata on first render and on every poll tick. Until now the Readarr tab rendered but never fetched anything. Guard the conditional arms the way the other servarrs do: the manual author search only fetches when no releases are cached, book history only when the books table is populated, and the manual book search only when the book details modal exists with no cached releases. Add the selection id helpers the arms read from, named to avoid colliding with the equivalents the other servarrs already define on App. Book edition details gets no arm on purpose. There is no endpoint to fetch a single edition, and the popup is fully populated before it opens. --- src/app/readarr/mod.rs | 204 ++++++++- src/app/readarr/readarr_tests.rs | 694 ++++++++++++++++++++++++++++++- 2 files changed, 892 insertions(+), 6 deletions(-) diff --git a/src/app/readarr/mod.rs b/src/app/readarr/mod.rs index 70f89f2c..ce719e24 100644 --- a/src/app/readarr/mod.rs +++ b/src/app/readarr/mod.rs @@ -1,5 +1,8 @@ use super::App; -use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::{ + models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, + network::readarr_network::ReadarrEvent, +}; pub mod readarr_context_clues; @@ -10,12 +13,179 @@ mod readarr_tests; impl App<'_> { pub(super) async fn dispatch_by_readarr_block( &mut self, - _active_readarr_block: &ActiveReadarrBlock, + active_readarr_block: &ActiveReadarrBlock, ) { + match active_readarr_block { + ActiveReadarrBlock::Authors => { + self + .dispatch_network_event(ReadarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetMetadataProfiles.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::ListAuthors.into()) + .await; + } + ActiveReadarrBlock::Blocklist => { + self + .dispatch_network_event(ReadarrEvent::GetBlocklist.into()) + .await; + } + ActiveReadarrBlock::Downloads => { + self + .dispatch_network_event(ReadarrEvent::GetDownloads(500).into()) + .await; + } + ActiveReadarrBlock::AuthorDetails => { + self + .dispatch_network_event(ReadarrEvent::GetBooks(self.extract_author_id().await).into()) + .await; + } + ActiveReadarrBlock::AuthorHistory => { + self + .dispatch_network_event( + ReadarrEvent::GetAuthorHistory(self.extract_author_id().await).into(), + ) + .await; + } + ActiveReadarrBlock::ManualAuthorSearch => { + if self.data.readarr_data.author_releases.is_empty() { + self + .dispatch_network_event( + ReadarrEvent::GetAuthorReleases(self.extract_author_id().await).into(), + ) + .await; + } + } + ActiveReadarrBlock::BookDetails => { + let book_id = self.extract_book_id().await; + self + .dispatch_network_event(ReadarrEvent::GetBookEditions(book_id).into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetBookFiles(book_id).into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetDownloads(500).into()) + .await; + } + ActiveReadarrBlock::BookHistory => { + if !self.data.readarr_data.books.is_empty() { + self + .dispatch_network_event( + ReadarrEvent::GetBookHistory( + self.extract_author_id().await, + self.extract_book_id().await, + ) + .into(), + ) + .await; + } + } + ActiveReadarrBlock::ManualBookSearch => { + match self.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if book_details_modal.book_releases.is_empty() => { + self + .dispatch_network_event( + ReadarrEvent::GetBookReleases(self.extract_book_id().await).into(), + ) + .await; + } + _ => (), + } + } + ActiveReadarrBlock::AddAuthorSearchResults => { + self + .dispatch_network_event( + ReadarrEvent::SearchNewAuthor(self.extract_add_new_author_search_query().await).into(), + ) + .await; + } + ActiveReadarrBlock::History => { + self + .dispatch_network_event(ReadarrEvent::GetHistory(500).into()) + .await + } + ActiveReadarrBlock::RootFolders => { + self + .dispatch_network_event(ReadarrEvent::GetRootFolders.into()) + .await; + } + ActiveReadarrBlock::Indexers => { + self + .dispatch_network_event(ReadarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetIndexers.into()) + .await; + } + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + self + .dispatch_network_event(ReadarrEvent::GetAllIndexerSettings.into()) + .await; + } + ActiveReadarrBlock::TestIndexer => { + self + .dispatch_network_event( + ReadarrEvent::TestIndexer(self.extract_readarr_indexer_id().await).into(), + ) + .await; + } + ActiveReadarrBlock::TestAllIndexers => { + self + .dispatch_network_event(ReadarrEvent::TestAllIndexers.into()) + .await; + } + ActiveReadarrBlock::System => { + self + .dispatch_network_event(ReadarrEvent::GetTasks.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetQueuedEvents.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetLogs(500).into()) + .await; + } + ActiveReadarrBlock::SystemUpdates => { + self + .dispatch_network_event(ReadarrEvent::GetUpdates.into()) + .await; + } + _ => (), + } + self.check_for_readarr_prompt_action().await; self.reset_tick_count(); } + async fn extract_add_new_author_search_query(&self) -> String { + self + .data + .readarr_data + .add_author_search + .as_ref() + .expect("Add author search is empty") + .text + .clone() + } + + async fn extract_author_id(&self) -> i64 { + self.data.readarr_data.authors.current_selection().id + } + + async fn extract_book_id(&self) -> i64 { + self.data.readarr_data.books.current_selection().id + } + + async fn extract_readarr_indexer_id(&self) -> i64 { + self.data.readarr_data.indexers.current_selection().id + } + async fn check_for_readarr_prompt_action(&mut self) { if self.data.readarr_data.prompt_confirm { self.data.readarr_data.prompt_confirm = false; @@ -28,6 +198,7 @@ impl App<'_> { pub(super) async fn readarr_on_tick(&mut self, active_readarr_block: ActiveReadarrBlock) { if self.is_first_render { + self.refresh_readarr_metadata().await; self.dispatch_by_readarr_block(&active_readarr_block).await; self.is_first_render = false; return; @@ -35,6 +206,7 @@ impl App<'_> { if self.should_refresh { self.dispatch_by_readarr_block(&active_readarr_block).await; + self.refresh_readarr_metadata().await; } if self.is_routing { @@ -44,5 +216,33 @@ impl App<'_> { self.dispatch_by_readarr_block(&active_readarr_block).await; } } + + if self.tick_count.is_multiple_of(self.tick_until_poll) { + self.refresh_readarr_metadata().await; + } + } + + async fn refresh_readarr_metadata(&mut self) { + self + .dispatch_network_event(ReadarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetMetadataProfiles.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetRootFolders.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetDownloads(500).into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetDiskSpace.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetStatus.into()) + .await; } } diff --git a/src/app/readarr/readarr_tests.rs b/src/app/readarr/readarr_tests.rs index 1aa8a0cc..2b771b4e 100644 --- a/src/app/readarr/readarr_tests.rs +++ b/src/app/readarr/readarr_tests.rs @@ -1,8 +1,506 @@ #[cfg(test)] mod tests { use crate::app::App; + use crate::models::readarr_models::{Author, Book, ReadarrRelease}; + use crate::models::servarr_data::readarr::modals::BookDetailsModal; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; - use pretty_assertions::assert_eq; + use crate::models::servarr_models::Indexer; + use crate::network::NetworkEvent; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::{assert_eq, assert_str_eq}; + use tokio::sync::mpsc; + + #[tokio::test] + async fn test_dispatch_by_readarr_block_authors() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Authors) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::ListAuthors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_readarr_block_author_details() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::AuthorDetails) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetBooks(1).into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_blocklist_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Blocklist) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetBlocklist.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_author_history_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::AuthorHistory) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetAuthorHistory(1).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_author_search_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualAuthorSearch) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetAuthorReleases(1).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_author_search_block_author_releases_non_empty() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_releases + .set_items(vec![ReadarrRelease::default()]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualAuthorSearch) + .await; + + assert!(!app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_book_details_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.data.readarr_data.books.set_items(vec![Book { + id: 2, + ..Book::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::BookDetails) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetBookEditions(2).into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetBookFiles(2).into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_book_history_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.data.readarr_data.books.set_items(vec![Book { + id: 2, + ..Book::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::BookHistory) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetBookHistory(1, 2).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_book_history_block_no_op_when_books_table_is_empty() { + let (tx, _) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::BookHistory) + .await; + + assert!(!app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_book_search_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.data.readarr_data.books.set_items(vec![Book { + id: 2, + ..Book::default() + }]); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualBookSearch) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetBookReleases(2).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_book_search_block_is_loading() { + let mut app = App { + is_loading: true, + ..App::test_default() + }; + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualBookSearch) + .await; + + assert!(app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_book_search_block_book_releases_non_empty() { + let mut app = App::test_default(); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_releases + .set_items(vec![ReadarrRelease::default()]); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualBookSearch) + .await; + + assert!(!app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_book_edition_details_block_does_not_dispatch() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.prompt_confirm = false; + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::BookEditionDetails) + .await; + + assert!(!app.is_loading); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_downloads_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Downloads) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_readarr_block_add_author_search_results() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.add_author_search = Some("test author".into()); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::AddAuthorSearchResults) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::SearchNewAuthor("test author".to_owned()).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_history_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::History) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetHistory(500).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_root_folders_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::RootFolders) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetRootFolders.into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_indexers_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Indexers) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetIndexers.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_all_indexer_settings_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::AllIndexerSettingsPrompt) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetAllIndexerSettings.into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_test_indexer_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.indexers.set_items(vec![Indexer { + id: 3, + ..Indexer::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::TestIndexer) + .await; + + assert!(app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::TestIndexer(3).into() + ); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_test_all_indexers_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::TestAllIndexers) + .await; + + assert!(app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::TestAllIndexers.into() + ); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_system_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::System) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTasks.into()); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQueuedEvents.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetLogs(500).into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_system_updates_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::SystemUpdates) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetUpdates.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } #[tokio::test] async fn test_dispatch_by_readarr_block_resets_the_tick_count() { @@ -55,21 +553,93 @@ mod tests { } #[tokio::test] - async fn test_readarr_on_tick_first_render() { + async fn test_check_for_readarr_prompt_action() { + let (tx, mut rx) = mpsc::channel::(500); let mut app = App::test_default(); app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::GetStatus); + + app.check_for_readarr_prompt_action().await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetStatus.into()); + assert!(app.should_refresh); + assert_eq!(app.data.readarr_data.prompt_confirm_action, None); + } + + #[tokio::test] + async fn test_readarr_refresh_metadata() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.is_routing = true; + + app.refresh_readarr_metadata().await; + + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetRootFolders.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetDiskSpace.into()); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetStatus.into()); + assert!(app.is_loading); + } + + #[tokio::test] + async fn test_readarr_on_tick_first_render() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); app.is_first_render = true; app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetRootFolders.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetDiskSpace.into()); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetStatus.into()); + assert!(app.is_loading); assert!(!app.data.readarr_data.prompt_confirm); assert!(!app.is_first_render); } #[tokio::test] async fn test_readarr_on_tick_routing() { + let (tx, mut rx) = mpsc::channel::(500); let mut app = App::test_default(); app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); app.is_routing = true; app.should_refresh = true; app.is_first_render = false; @@ -77,14 +647,19 @@ mod tests { app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); assert!(!app.data.readarr_data.prompt_confirm); - assert!(!app.cancellation_token.is_cancelled()); } #[tokio::test] async fn test_readarr_on_tick_routing_while_long_request_is_running_should_cancel_request() { + let (tx, _) = mpsc::channel::(500); let mut app = App::test_default(); app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); app.is_routing = true; app.should_refresh = false; app.is_first_render = false; @@ -97,16 +672,127 @@ mod tests { #[tokio::test] async fn test_readarr_on_tick_should_refresh() { + let (tx, mut rx) = mpsc::channel::(500); let mut app = App::test_default(); app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); app.should_refresh = true; app.is_first_render = false; app.tick_count = 1; app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); assert!(app.should_refresh); assert!(!app.data.readarr_data.prompt_confirm); - assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_readarr_on_tick_should_refresh_does_not_cancel_prompt_requests() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.is_loading = true; + app.is_routing = true; + app.should_refresh = true; + app.is_first_render = false; + app.tick_count = 1; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(app.is_loading); + assert!(app.should_refresh); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.cancellation_token.is_cancelled()); + } + + #[tokio::test] + async fn test_readarr_on_tick_network_tick_frequency() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.is_first_render = false; + app.tick_count = 2; + app.tick_until_poll = 2; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetRootFolders.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(app.is_loading); + } + + #[tokio::test] + async fn test_extract_add_new_author_search_query() { + let app = App::test_default_fully_populated(); + + let query = app.extract_add_new_author_search_query().await; + + assert_str_eq!(query, "Test Author"); + } + + #[tokio::test] + #[should_panic(expected = "Add author search is empty")] + async fn test_extract_add_new_author_search_query_panics_when_the_query_is_not_set() { + let app = App::test_default(); + + app.extract_add_new_author_search_query().await; + } + + #[tokio::test] + async fn test_extract_author_id() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + + assert_eq!(app.extract_author_id().await, 1); + } + + #[tokio::test] + async fn test_extract_book_id() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(vec![Book { + id: 2, + ..Book::default() + }]); + + assert_eq!(app.extract_book_id().await, 2); + } + + #[tokio::test] + async fn test_extract_readarr_indexer_id() { + let mut app = App::test_default(); + app.data.readarr_data.indexers.set_items(vec![Indexer { + id: 3, + ..Indexer::default() + }]); + + assert_eq!(app.extract_readarr_indexer_id().await, 3); } } From 06e2d7841efbfb26a375b72302291914b4c76962 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 16:57:47 -0600 Subject: [PATCH 065/170] feat(readarr): handle key events for the authors library table Add LibraryHandler, which owns the authors table and its sort, search, and filter overlays through TableHandlingConfig, along with the add, edit, toggle monitoring, update all, and refresh key actions and the delete prompt. Dispatch into it from ReadarrHandler and add the main tab left and right movement helper. authors_sorting_options offers eight sorts: name, status, quality profile, metadata profile, books, size, monitored, and tags. It omits the type and tracks sorts that the artists table has. GET /author never returns authorType, so that column would be empty on every row, and author statistics carry no track count. Also cover the Readarr arm of handle_events, which was previously unreachable from any test: neutering the arm entirely left the suite green. --- src/handlers/handlers_tests.rs | 7 + .../library/library_handler_tests.rs | 1016 +++++++++++++++++ src/handlers/readarr_handlers/library/mod.rs | 278 +++++ src/handlers/readarr_handlers/mod.rs | 30 +- .../readarr_handlers/readarr_handler_tests.rs | 129 ++- 5 files changed, 1458 insertions(+), 2 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/library_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/library/mod.rs diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index 51519b95..72b879d0 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -3,6 +3,7 @@ mod tests { use crate::assert_modal_absent; use crate::assert_navigation_pushed; use crate::models::radarr_models::Movie; + use crate::models::readarr_models::Author; use crate::models::sonarr_models::Series; use crate::{assert_modal_present, assert_navigation_popped}; use pretty_assertions::assert_eq; @@ -41,6 +42,7 @@ mod tests { #[rstest] #[case(ActiveRadarrBlock::Movies.into(), ActiveRadarrBlock::SearchMovie.into())] #[case(ActiveSonarrBlock::Series.into(), ActiveSonarrBlock::SearchSeries.into())] + #[case(ActiveReadarrBlock::Authors.into(), ActiveReadarrBlock::SearchAuthors.into())] fn test_handle_events(#[case] base_block: Route, #[case] top_block: Route) { let mut app = App::test_default(); app.push_navigation_stack(base_block); @@ -55,6 +57,11 @@ mod tests { .radarr_data .movies .set_items(vec![Movie::default()]); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); handle_events(DEFAULT_KEYBINDINGS.esc.key, &mut app); diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs new file mode 100644 index 00000000..8d7292b0 --- /dev/null +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -0,0 +1,1016 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use serde_json::Number; + use std::cmp::Ordering; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::{LibraryHandler, authors_sorting_options}; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS}; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_str_eq; + + use super::*; + use crate::simple_stateful_iterable_vec; + + #[rstest] + fn test_authors_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(simple_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new(key, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 2" + ); + + LibraryHandler::new(key, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + + #[rstest] + fn test_authors_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(simple_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new(key, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_str_eq; + + use super::*; + use crate::extended_stateful_iterable_vec; + + #[test] + fn test_authors_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(extended_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 3" + ); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + + #[test] + fn test_authors_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(extended_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + } + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_delete_prompt; + use crate::models::servarr_data::readarr::readarr_data::DELETE_AUTHOR_SELECTION_BLOCKS; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_author_delete() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + assert_delete_prompt!( + LibraryHandler, + app, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::DeleteAuthorPrompt + ); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + DELETE_AUTHOR_SELECTION_BLOCKS + ); + } + + #[test] + fn test_author_delete_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_authors_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(0); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::System.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::System.into()); + } + + #[rstest] + fn test_authors_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(0); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Downloads.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Downloads.into()); + } + + #[rstest] + fn test_left_right_update_all_authors_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + key, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + LibraryHandler::new( + key, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_popped; + use crate::assert_navigation_pushed; + use crate::network::readarr_network::ReadarrEvent; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_author_details_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorDetails.into()); + } + + #[test] + fn test_author_details_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_update_all_authors_prompt_confirm_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + LibraryHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::UpdateAllAuthors + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_update_all_authors_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + LibraryHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_esc { + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_update_all_authors_prompt_blocks_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + LibraryHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::models::servarr_data::readarr::readarr_data::EDIT_AUTHOR_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + assert_modal_absent, assert_modal_present, assert_navigation_popped, assert_navigation_pushed, + }; + + #[test] + fn test_author_add_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AddAuthorSearchInput.into()); + assert!(app.ignore_special_keys_for_textbox_input); + assert_modal_present!(app.data.readarr_data.add_author_search); + } + + #[test] + fn test_author_add_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(!app.ignore_special_keys_for_textbox_input); + assert_modal_absent!(app.data.readarr_data.add_author_search); + } + + #[test] + fn test_author_edit_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.data.readarr_data.quality_profile_map = + bimap::BiMap::from_iter([(0i64, "Default Quality".to_owned())]); + app.data.readarr_data.metadata_profile_map = + bimap::BiMap::from_iter([(0i64, "Default Metadata".to_owned())]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + assert_modal_present!(app.data.readarr_data.edit_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_AUTHOR_SELECTION_BLOCKS + ); + } + + #[test] + fn test_author_edit_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + } + + #[test] + fn test_toggle_monitoring_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_routing = false; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.is_routing); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::ToggleAuthorMonitoring(0) + ); + } + + #[test] + fn test_toggle_monitoring_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_routing = false; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.is_routing); + } + + #[test] + fn test_update_all_authors_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + } + + #[test] + fn test_update_all_authors_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_refresh_authors_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_authors_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_update_all_authors_prompt_confirm() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::UpdateAllAuthors + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + } + + #[test] + fn test_authors_sorting_options_name() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.author_name + .text + .to_lowercase() + .cmp(&b.author_name.text.to_lowercase()) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[0].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Name"); + } + + #[test] + fn test_authors_sorting_options_status() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[1].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Status"); + } + + #[test] + fn test_authors_sorting_options_quality_profile() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = + |a, b| a.quality_profile_id.cmp(&b.quality_profile_id); + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[2].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Quality Profile"); + } + + #[test] + fn test_authors_sorting_options_metadata_profile() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = + |a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id); + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[3].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Metadata Profile"); + } + + #[test] + fn test_authors_sorting_options_books() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.book_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.book_count)) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[4].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Books"); + } + + #[test] + fn test_authors_sorting_options_size() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[5].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_authors_sorting_options_monitored() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| a.monitored.cmp(&b.monitored); + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[6].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Monitored"); + } + + #[test] + fn test_authors_sorting_options_tags() { + let expected_cmp_fn: fn(&Author, &Author) -> 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_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[7].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Tags"); + } + + #[test] + fn test_library_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|readarr_block| { + if LIBRARY_BLOCKS.contains(&readarr_block) { + assert!( + LibraryHandler::accepts(readarr_block), + "{readarr_block} is not accepted by the LibraryHandler" + ); + } else { + assert!(!LibraryHandler::accepts(readarr_block)); + } + }); + } + + #[rstest] + fn test_library_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_library_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_library_handler_not_ready_when_authors_is_empty() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_library_handler_ready_when_not_loading_and_authors_is_not_empty() { + let mut app = App::test_default(); + app.is_loading = false; + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ); + + assert!(handler.is_ready()); + } + + fn authors_vec() -> Vec { + vec![ + Author { + id: 3, + author_name: "Test Author 1".into(), + status: AuthorStatus::Ended, + quality_profile_id: 1, + metadata_profile_id: 1, + monitored: false, + tags: vec![Number::from(1), Number::from(2)], + statistics: Some(AuthorStatistics { + book_count: 5, + size_on_disk: 789, + ..AuthorStatistics::default() + }), + ..Author::default() + }, + Author { + id: 2, + author_name: "Test Author 2".into(), + status: AuthorStatus::Continuing, + quality_profile_id: 2, + metadata_profile_id: 2, + monitored: false, + tags: vec![Number::from(1), Number::from(3)], + statistics: Some(AuthorStatistics { + book_count: 10, + size_on_disk: 456, + ..AuthorStatistics::default() + }), + ..Author::default() + }, + Author { + id: 1, + author_name: "Test Author 3".into(), + status: AuthorStatus::Deleted, + quality_profile_id: 3, + metadata_profile_id: 3, + monitored: true, + tags: vec![Number::from(2), Number::from(3)], + statistics: Some(AuthorStatistics { + book_count: 3, + size_on_disk: 123, + ..AuthorStatistics::default() + }), + ..Author::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs new file mode 100644 index 00000000..61bfbb95 --- /dev/null +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -0,0 +1,278 @@ +use crate::{ + app::App, + event::Key, + handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}, + matches_key, + models::{ + BlockSelectionState, HorizontallyScrollableText, + readarr_models::Author, + servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_SELECTION_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, + LIBRARY_BLOCKS, + }, + stateful_table::SortOption, + }, + network::readarr_network::ReadarrEvent, +}; + +use super::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; + +#[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_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl LibraryHandler<'_, '_> { + fn extract_author_id(&self) -> i64 { + self.app.data.readarr_data.authors.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, 'b> { + fn handle(&mut self) { + let authors_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::Authors.into()) + .sorting_block(ActiveReadarrBlock::AuthorsSortPrompt.into()) + .sort_options(authors_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchAuthors.into()) + .search_error_block(ActiveReadarrBlock::SearchAuthorsError.into()) + .search_field_fn(|author| &author.author_name.text) + .filtering_block(ActiveReadarrBlock::FilterAuthors.into()) + .filter_error_block(ActiveReadarrBlock::FilterAuthorsError.into()) + .filter_field_fn(|author| &author.author_name.text); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.authors, + authors_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + LIBRARY_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> LibraryHandler<'a, 'b> { + LibraryHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.authors.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) { + if self.active_readarr_block == ActiveReadarrBlock::Authors { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Authors => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::UpdateAllAuthorsPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Authors => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + } + ActiveReadarrBlock::UpdateAllAuthorsPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateAllAuthors); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::UpdateAllAuthorsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => { + handle_clear_errors(self.app); + } + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::Authors => match key { + _ if matches_key!(add, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + self.app.data.readarr_data.add_author_search = + Some(HorizontallyScrollableText::default()); + self.app.ignore_special_keys_for_textbox_input = true; + } + _ if matches_key!(toggle_monitoring, key) => { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::ToggleAuthorMonitoring(self.extract_author_id()), + ); + + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + _ if matches_key!(edit, key) => { + self.app.data.readarr_data.edit_author_modal = Some((&self.app.data.readarr_data).into()); + self + .app + .push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + } + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + }, + ActiveReadarrBlock::UpdateAllAuthorsPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateAllAuthors); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +fn authors_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Name", + cmp_fn: Some(|a, b| { + a.author_name + .text + .to_lowercase() + .cmp(&b.author_name.text.to_lowercase()) + }), + }, + SortOption { + name: "Status", + cmp_fn: Some(|a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }), + }, + SortOption { + name: "Quality Profile", + cmp_fn: Some(|a, b| a.quality_profile_id.cmp(&b.quality_profile_id)), + }, + SortOption { + name: "Metadata Profile", + cmp_fn: Some(|a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id)), + }, + SortOption { + name: "Books", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.book_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.book_count)) + }), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }), + }, + 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/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 9f389546..5ffee4ce 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -1,9 +1,13 @@ use super::KeyEventHandler; +use crate::handlers::readarr_handlers::library::LibraryHandler; use crate::models::Route; use crate::{ - app::App, event::Key, models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, + app::App, event::Key, matches_key, + models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, }; +mod library; + #[cfg(test)] #[path = "readarr_handler_tests.rs"] mod readarr_handler_tests; @@ -16,6 +20,15 @@ pub(super) struct ReadarrHandler<'a, 'b> { } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_readarr_block { + _ if LibraryHandler::accepts(self.active_readarr_block) => { + LibraryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } + _ => self.handle_key_event(), + } + } + fn accepts(_active_block: ActiveReadarrBlock) -> bool { true } @@ -72,3 +85,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, self.app.get_current_route() } } + +pub fn handle_change_tab_left_right_keys(app: &mut App<'_>, key: Key) { + let key_ref = key; + match key_ref { + _ if matches_key!(left, key, app.ignore_special_keys_for_textbox_input) => { + app.data.readarr_data.main_tabs.previous(); + app.pop_and_push_navigation_stack(app.data.readarr_data.main_tabs.get_active_route()); + } + _ if matches_key!(right, key, app.ignore_special_keys_for_textbox_input) => { + app.data.readarr_data.main_tabs.next(); + app.pop_and_push_navigation_stack(app.data.readarr_data.main_tabs.get_active_route()); + } + _ => (), + } +} diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index 64c4df03..d467d874 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -3,8 +3,9 @@ mod tests { use crate::app::App; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::handlers::KeyEventHandler; - use crate::handlers::readarr_handlers::ReadarrHandler; + use crate::handlers::readarr_handlers::{ReadarrHandler, handle_change_tab_left_right_keys}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::{assert_navigation_pushed, test_handler_delegation}; use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; @@ -49,4 +50,130 @@ mod tests { assert!(ReadarrHandler::accepts(readarr_block)); } } + + #[rstest] + #[case(0, ActiveReadarrBlock::System, ActiveReadarrBlock::Downloads)] + #[case(1, ActiveReadarrBlock::Authors, ActiveReadarrBlock::Blocklist)] + #[case(2, ActiveReadarrBlock::Downloads, ActiveReadarrBlock::History)] + #[case(3, ActiveReadarrBlock::Blocklist, ActiveReadarrBlock::RootFolders)] + #[case(4, ActiveReadarrBlock::History, ActiveReadarrBlock::Indexers)] + #[case(5, ActiveReadarrBlock::RootFolders, ActiveReadarrBlock::System)] + #[case(6, ActiveReadarrBlock::Indexers, ActiveReadarrBlock::Authors)] + fn test_readarr_handler_change_tab_left_right_keys( + #[case] index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.key); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + left_block.into() + ); + assert_navigation_pushed!(app, left_block.into()); + + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.key); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + right_block.into() + ); + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::System, ActiveReadarrBlock::Downloads)] + #[case(1, ActiveReadarrBlock::Authors, ActiveReadarrBlock::Blocklist)] + #[case(2, ActiveReadarrBlock::Downloads, ActiveReadarrBlock::History)] + #[case(3, ActiveReadarrBlock::Blocklist, ActiveReadarrBlock::RootFolders)] + #[case(4, ActiveReadarrBlock::History, ActiveReadarrBlock::Indexers)] + #[case(5, ActiveReadarrBlock::RootFolders, ActiveReadarrBlock::System)] + #[case(6, ActiveReadarrBlock::Indexers, ActiveReadarrBlock::Authors)] + fn test_readarr_handler_change_tab_left_right_keys_alt_navigation( + #[case] index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + left_block.into() + ); + assert_navigation_pushed!(app, left_block.into()); + + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + right_block.into() + ); + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::Authors)] + #[case(1, ActiveReadarrBlock::Downloads)] + #[case(2, ActiveReadarrBlock::Blocklist)] + #[case(3, ActiveReadarrBlock::History)] + #[case(4, ActiveReadarrBlock::RootFolders)] + #[case(5, ActiveReadarrBlock::Indexers)] + #[case(6, ActiveReadarrBlock::System)] + fn test_readarr_handler_change_tab_left_right_keys_alt_navigation_no_op_when_ignoring_quit_key( + #[case] index: usize, + #[case] block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(block.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + block.into() + ); + assert_eq!(app.get_current_route(), block.into()); + + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + block.into() + ); + assert_eq!(app.get_current_route(), block.into()); + } + + #[rstest] + fn test_delegates_library_blocks_to_library_handler( + #[values( + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::AuthorsSortPrompt, + ActiveReadarrBlock::FilterAuthors, + ActiveReadarrBlock::FilterAuthorsError, + ActiveReadarrBlock::SearchAuthors, + ActiveReadarrBlock::SearchAuthorsError, + ActiveReadarrBlock::UpdateAllAuthorsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::Authors, + active_readarr_block + ); + } } From fb465b06f8512e1490b14818166fbe8486829f73 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 00:55:36 -0600 Subject: [PATCH 066/170] feat(readarr): handle key events for the author details view --- .../history/history_handler_tests.rs | 125 ++ src/handlers/readarr_handlers/history/mod.rs | 44 + .../library/author_details_handler.rs | 448 +++++++ .../library/author_details_handler_tests.rs | 1183 +++++++++++++++++ .../library/library_handler_tests.rs | 10 +- src/handlers/readarr_handlers/library/mod.rs | 14 +- src/handlers/readarr_handlers/mod.rs | 1 + 7 files changed, 1821 insertions(+), 4 deletions(-) create mode 100644 src/handlers/readarr_handlers/history/history_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/history/mod.rs create mode 100644 src/handlers/readarr_handlers/library/author_details_handler.rs create mode 100644 src/handlers/readarr_handlers/library/author_details_handler_tests.rs diff --git a/src/handlers/readarr_handlers/history/history_handler_tests.rs b/src/handlers/readarr_handlers/history/history_handler_tests.rs new file mode 100644 index 00000000..7a29b745 --- /dev/null +++ b/src/handlers/readarr_handlers/history/history_handler_tests.rs @@ -0,0 +1,125 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::handlers::readarr_handlers::history::history_sorting_options; + use crate::models::readarr_models::{ReadarrHistoryEventType, ReadarrHistoryItem}; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + #[test] + fn test_history_sorting_options_source_title() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = |a, b| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[0].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Source Title"); + } + + #[test] + fn test_history_sorting_options_event_type() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = |a, b| { + a.event_type + .to_string() + .to_lowercase() + .cmp(&b.event_type.to_string().to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[1].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Event Type"); + } + + #[test] + fn test_history_sorting_options_quality() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = |a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[2].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + #[test] + fn test_history_sorting_options_date() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = + |a, b| a.date.cmp(&b.date); + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[3].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Date"); + } + + fn history_vec() -> Vec { + vec![ + ReadarrHistoryItem { + id: 3, + source_title: "test 1".into(), + event_type: ReadarrHistoryEventType::Grabbed, + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + ..ReadarrHistoryItem::default() + }, + ReadarrHistoryItem { + id: 2, + source_title: "test 2".into(), + event_type: ReadarrHistoryEventType::DownloadImported, + quality: QualityWrapper { + quality: Quality { + name: "AZW3".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + ..ReadarrHistoryItem::default() + }, + ReadarrHistoryItem { + id: 1, + source_title: "test 3".into(), + event_type: ReadarrHistoryEventType::BookFileDeleted, + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + ..ReadarrHistoryItem::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/history/mod.rs b/src/handlers/readarr_handlers/history/mod.rs new file mode 100644 index 00000000..5ee320e4 --- /dev/null +++ b/src/handlers/readarr_handlers/history/mod.rs @@ -0,0 +1,44 @@ +use crate::models::readarr_models::ReadarrHistoryItem; +use crate::models::stateful_table::SortOption; + +#[cfg(test)] +#[path = "history_handler_tests.rs"] +mod history_handler_tests; + +pub(in crate::handlers::readarr_handlers) fn history_sorting_options() +-> Vec> { + vec![ + SortOption { + name: "Source Title", + cmp_fn: Some(|a, b| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }), + }, + SortOption { + name: "Event Type", + cmp_fn: Some(|a, b| { + a.event_type + .to_string() + .to_lowercase() + .cmp(&b.event_type.to_string().to_lowercase()) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }), + }, + SortOption { + name: "Date", + cmp_fn: Some(|a, b| a.date.cmp(&b.date)), + }, + ] +} diff --git a/src/handlers/readarr_handlers/library/author_details_handler.rs b/src/handlers/readarr_handlers/library/author_details_handler.rs new file mode 100644 index 00000000..d10758a8 --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_details_handler.rs @@ -0,0 +1,448 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::history::history_sorting_options; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, DELETE_BOOK_SELECTION_BLOCKS, + EDIT_AUTHOR_SELECTION_BLOCKS, +}; +use crate::models::servarr_models::ReleaseDownloadBody; +use crate::models::stateful_table::SortOption; +use crate::models::{BlockSelectionState, Route}; +use crate::network::readarr_network::ReadarrEvent; +use serde_json::Number; + +#[cfg(test)] +#[path = "author_details_handler_tests.rs"] +mod author_details_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct AuthorDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl AuthorDetailsHandler<'_, '_> { + fn extract_author_id(&self) -> i64 { + self.app.data.readarr_data.authors.current_selection().id + } + + fn extract_book_id(&self) -> i64 { + self.app.data.readarr_data.books.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let books_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::AuthorDetails.into()) + .searching_block(ActiveReadarrBlock::SearchBooks.into()) + .search_error_block(ActiveReadarrBlock::SearchBooksError.into()) + .search_field_fn(|book: &Book| &book.title.text); + + let author_history_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::AuthorHistory.into()) + .sorting_block(ActiveReadarrBlock::AuthorHistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchAuthorHistory.into()) + .search_error_block(ActiveReadarrBlock::SearchAuthorHistoryError.into()) + .search_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text) + .filtering_block(ActiveReadarrBlock::FilterAuthorHistory.into()) + .filter_error_block(ActiveReadarrBlock::FilterAuthorHistoryError.into()) + .filter_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text); + + let author_releases_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::ManualAuthorSearch.into()) + .sorting_block(ActiveReadarrBlock::ManualAuthorSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.books, + books_table_handling_config, + ) && !handle_table( + self, + |app| &mut app.data.readarr_data.author_history, + author_history_table_handling_config, + ) && !handle_table( + self, + |app| &mut app.data.readarr_data.author_releases, + author_releases_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + AUTHOR_DETAILS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> AuthorDetailsHandler<'a, 'b> { + AuthorDetailsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + match self.active_readarr_block { + ActiveReadarrBlock::AuthorHistory => !self.app.data.readarr_data.author_history.is_empty(), + ActiveReadarrBlock::ManualAuthorSearch => { + !self.app.data.readarr_data.author_releases.is_empty() + } + _ => true, + } + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::AuthorDetails { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AuthorDetails + | ActiveReadarrBlock::AuthorHistory + | ActiveReadarrBlock::ManualAuthorSearch => match self.key { + _ if matches_key!(left, self.key) => { + self.app.data.readarr_data.author_info_tabs.previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .author_info_tabs + .get_active_route(), + ); + } + _ if matches_key!(right, self.key) => { + self.app.data.readarr_data.author_info_tabs.next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .author_info_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt + | ActiveReadarrBlock::AutomaticallySearchAuthorPrompt + | ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + handle_prompt_toggle(self.app, self.key); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AuthorDetails if !self.app.data.readarr_data.books.is_empty() => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + } + ActiveReadarrBlock::AuthorHistory + if !self.app.data.readarr_data.author_history.is_empty() => + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AuthorHistoryDetails.into()); + } + ActiveReadarrBlock::ManualAuthorSearch => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + } + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .author_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticAuthorSearch(self.extract_author_id()), + ); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::UpdateAndScanAuthorPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::UpdateAndScanAuthor(self.extract_author_id())); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::UpdateAndScanAuthorPrompt + | ActiveReadarrBlock::AutomaticallySearchAuthorPrompt + | ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::AuthorHistoryDetails => { + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AuthorHistory => { + if self + .app + .data + .readarr_data + .author_history + .filtered_items + .is_some() + { + self.app.data.readarr_data.author_history.reset_filter(); + } else { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_author_info_tabs(); + } + } + ActiveReadarrBlock::AuthorDetails | ActiveReadarrBlock::ManualAuthorSearch => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_author_info_tabs(); + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::AuthorDetails => match self.key { + _ if matches_key!(refresh, key) => self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()), + _ if matches_key!(auto_search, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into()); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + } + _ if matches_key!(edit, key) => { + self.app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(self.active_readarr_block), + ) + .into(), + ); + self.app.data.readarr_data.edit_author_modal = Some((&self.app.data.readarr_data).into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + } + _ if matches_key!(toggle_monitoring, key) + && !self.app.data.readarr_data.books.is_empty() => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::ToggleBookMonitoring(self.extract_book_id())); + + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + _ => (), + }, + ActiveReadarrBlock::AuthorHistory | ActiveReadarrBlock::ManualAuthorSearch => { + match self.key { + _ if matches_key!(refresh, key) => self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()), + _ if matches_key!(auto_search, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into()); + } + _ if matches_key!(edit, key) => { + self.app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(self.active_readarr_block), + ) + .into(), + ); + self.app.data.readarr_data.edit_author_modal = + Some((&self.app.data.readarr_data).into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + } + _ => (), + } + } + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticAuthorSearch(self.extract_author_id()), + ); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::UpdateAndScanAuthorPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::UpdateAndScanAuthor(self.extract_author_id())); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .author_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +fn releases_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Source", + cmp_fn: Some(|a, b| a.protocol.cmp(&b.protocol)), + }, + SortOption { + name: "Age", + cmp_fn: Some(|a, b| a.age.cmp(&b.age)), + }, + SortOption { + name: "Rejected", + cmp_fn: Some(|a, b| a.rejected.cmp(&b.rejected)), + }, + SortOption { + name: "Title", + cmp_fn: Some(|a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }), + }, + SortOption { + name: "Indexer", + cmp_fn: Some(|a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase())), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| a.size.cmp(&b.size)), + }, + SortOption { + name: "Peers", + cmp_fn: Some(|a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + + seeder_a.cmp(&seeder_b) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| a.quality.cmp(&b.quality)), + }, + ] +} diff --git a/src/handlers/readarr_handlers/library/author_details_handler_tests.rs b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs new file mode 100644 index 00000000..aa028a8f --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs @@ -0,0 +1,1183 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use serde_json::Number; + use std::cmp::Ordering; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::{ + AuthorDetailsHandler, releases_sorting_options, + }; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ReadarrHistoryItem, ReadarrRelease}; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + mod test_handle_delete { + use super::*; + use crate::assert_delete_prompt; + use crate::event::Key; + use crate::models::readarr_models::Book; + use crate::models::servarr_data::readarr::readarr_data::DELETE_BOOK_SELECTION_BLOCKS; + use pretty_assertions::assert_eq; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_book_delete() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(vec![Book::default()]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + assert_delete_prompt!( + AuthorDetailsHandler, + app, + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::DeleteBookPrompt + ); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + DELETE_BOOK_SELECTION_BLOCKS + ); + } + } + + mod test_handle_left_right_action { + use rstest::rstest; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use pretty_assertions::assert_eq; + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + AuthorDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + #[case(ActiveReadarrBlock::AuthorDetails, ActiveReadarrBlock::AuthorHistory)] + #[case( + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + #[case( + ActiveReadarrBlock::ManualAuthorSearch, + ActiveReadarrBlock::AuthorDetails + )] + fn test_author_details_tabs_left_right_action( + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + #[values(true, false)] is_loading: bool, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = is_loading; + app.push_navigation_stack(right_block.into()); + app.data.readarr_data.author_info_tabs.index = app + .data + .readarr_data + .author_info_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + + AuthorDetailsHandler::new(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None).handle(); + + assert_eq!( + app.get_current_route(), + app.data.readarr_data.author_info_tabs.get_active_route() + ); + assert_navigation_pushed!(app, left_block.into()); + + AuthorDetailsHandler::new(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None).handle(); + + assert_eq!( + app.get_current_route(), + app.data.readarr_data.author_info_tabs.get_active_route() + ); + assert_navigation_pushed!(app, right_block.into()); + } + } + + mod test_handle_submit { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::readarr_models::{Author, ReadarrHistoryItem}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::torrent_release; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[rstest] + #[case( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ReadarrEvent::TriggerAutomaticAuthorSearch(1) + )] + #[case( + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ReadarrEvent::UpdateAndScanAuthor(1) + )] + fn test_author_details_prompt_confirm_submit( + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[rstest] + fn test_author_details_prompt_decline_submit( + #[values( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt + )] + prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_author_history_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_history + .set_items(vec![ReadarrHistoryItem::default()]); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorHistoryDetails.into()); + } + + #[test] + fn test_author_history_submit_no_op_when_author_history_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorHistory.into() + ); + } + + #[test] + fn test_author_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_manual_author_search_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_releases + .set_items(vec![torrent_release()]); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into() + ); + } + + #[test] + fn test_manual_author_search_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::ManualAuthorSearch.into() + ); + } + + #[test] + fn test_manual_author_search_confirm_prompt_confirm_submit() { + let mut app = App::test_default(); + let release = torrent_release(); + app + .data + .readarr_data + .author_releases + .set_items(vec![release.clone()]); + app.data.readarr_data.prompt_confirm = true; + let expected_action = ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: release.guid, + indexer_id: release.indexer_id, + }); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualAuthorSearch.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[test] + fn test_manual_author_search_confirm_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_releases + .set_items(vec![torrent_release()]); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualAuthorSearch.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + } + + mod test_handle_esc { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_popped; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::readarr_models::ReadarrHistoryItem; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_eq; + use ratatui::widgets::TableState; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_author_history_details_block_esc() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistoryDetails.into()); + + AuthorDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistoryDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorHistory.into()); + } + + #[test] + fn test_author_history_esc_resets_filter_if_one_is_set_instead_of_closing_the_window() { + let mut app = App::test_default(); + app.data.readarr_data.author_history = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(vec![ReadarrHistoryItem::default()]), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::AuthorHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorHistory.into() + ); + assert_none!(app.data.readarr_data.author_history.filter); + assert_none!(app.data.readarr_data.author_history.filtered_items); + assert_none!(app.data.readarr_data.author_history.filtered_state); + } + + #[rstest] + fn test_author_details_esc( + #[values( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt + )] + prompt_block: ActiveReadarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + } + + #[rstest] + fn test_author_details_blocks_esc( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.author_history.filter = None; + app.data.readarr_data.author_history.filtered_items = None; + app.data.readarr_data.author_history.filtered_state = None; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_is_empty!(app.data.readarr_data.books); + assert_is_empty!(app.data.readarr_data.author_releases); + assert_is_empty!(app.data.readarr_data.author_history); + assert_eq!(app.data.readarr_data.author_info_tabs.index, 0); + } + } + + mod test_handle_char_key_event { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::readarr_models::Author; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_AUTHOR_SELECTION_BLOCKS, + }; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::torrent_release; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_author_details_edit_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(active_readarr_block) + ) + .into() + ); + assert_modal_present!(app.data.readarr_data.edit_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_AUTHOR_SELECTION_BLOCKS + ); + } + + #[rstest] + fn test_author_details_edit_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + } + + #[test] + fn test_author_details_toggle_monitoring_key() { + let mut app = App::test_default_fully_populated(); + app.is_routing = false; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorDetails.into() + ); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.is_routing); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::ToggleBookMonitoring(1) + ); + } + + #[test] + fn test_author_details_toggle_monitoring_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.prompt_confirm = false; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorDetails.into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_author_details_toggle_monitoring_key_no_op_when_books_empty() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[rstest] + fn test_author_details_auto_search_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into() + ); + } + + #[rstest] + fn test_author_details_auto_search_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + } + + #[rstest] + fn test_author_details_update_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + } + + #[rstest] + fn test_author_details_update_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + } + + #[rstest] + fn test_author_details_refresh_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.is_routing = false; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, active_readarr_block.into()); + assert!(app.is_routing); + } + + #[rstest] + fn test_author_details_refresh_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + app.is_routing = false; + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + assert!(!app.is_routing); + } + + #[rstest] + #[case( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ReadarrEvent::TriggerAutomaticAuthorSearch(1) + )] + #[case( + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ReadarrEvent::UpdateAndScanAuthor(1) + )] + fn test_author_details_prompt_confirm_key( + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + #[values(ActiveReadarrBlock::AuthorDetails, ActiveReadarrBlock::AuthorHistory)] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, active_readarr_block.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[test] + fn test_manual_author_search_confirm_prompt_confirm_key() { + let mut app = App::test_default(); + let release = torrent_release(); + app + .data + .readarr_data + .author_releases + .set_items(vec![release.clone()]); + let expected_action = ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: release.guid, + indexer_id: release.indexer_id, + }); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualAuthorSearch.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + } + + #[test] + fn test_author_details_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|readarr_block| { + if AUTHOR_DETAILS_BLOCKS.contains(&readarr_block) { + assert!( + AuthorDetailsHandler::accepts(readarr_block), + "{readarr_block} is not accepted by the AuthorDetailsHandler" + ); + } else { + assert!(!AuthorDetailsHandler::accepts(readarr_block)); + } + }); + } + + #[test] + fn test_extract_author_id() { + let mut app = App::test_default_fully_populated(); + + let author_id = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .extract_author_id(); + + assert_eq!(author_id, 1); + } + + #[test] + fn test_extract_book_id() { + let mut app = App::test_default_fully_populated(); + + let book_id = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .extract_book_id(); + + assert_eq!(book_id, 1); + } + + #[rstest] + fn test_author_details_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_author_details_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.is_loading = true; + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_details_handler_is_ready_when_not_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.is_loading = false; + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_author_details_handler_is_not_ready_when_not_loading_and_author_history_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_details_handler_ready_when_not_loading_and_author_history_is_non_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .author_history + .set_items(vec![ReadarrHistoryItem::default()]); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_author_details_handler_is_not_ready_when_not_loading_and_author_releases_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_details_handler_ready_when_not_loading_and_author_releases_is_non_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .author_releases + .set_items(vec![ReadarrRelease::default()]); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_releases_sorting_options_source() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.protocol.cmp(&b.protocol); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[0].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Source"); + } + + #[test] + fn test_releases_sorting_options_age() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.age.cmp(&b.age); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[1].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Age"); + } + + #[test] + fn test_releases_sorting_options_rejected() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.rejected.cmp(&b.rejected); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[2].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Rejected"); + } + + #[test] + fn test_releases_sorting_options_title() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = |a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[3].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Title"); + } + + #[test] + fn test_releases_sorting_options_indexer() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase()); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[4].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Indexer"); + } + + #[test] + fn test_releases_sorting_options_size() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.size.cmp(&b.size); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[5].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_releases_sorting_options_peers() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = |a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + + seeder_a.cmp(&seeder_b) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[6].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Peers"); + } + + #[test] + fn test_releases_sorting_options_quality() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.quality.cmp(&b.quality); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[7].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + fn release_vec() -> Vec { + let release_a = ReadarrRelease { + protocol: "Protocol A".to_owned(), + age: 1, + title: HorizontallyScrollableText::from("Title A"), + indexer: "Indexer A".to_owned(), + size: 1, + rejected: true, + seeders: Some(Number::from(1)), + quality: QualityWrapper { + quality: Quality { + name: "Quality A".to_owned(), + }, + }, + ..ReadarrRelease::default() + }; + let release_b = ReadarrRelease { + protocol: "Protocol B".to_owned(), + age: 2, + title: HorizontallyScrollableText::from("title B"), + indexer: "indexer B".to_owned(), + size: 2, + rejected: false, + seeders: Some(Number::from(2)), + quality: QualityWrapper { + quality: Quality { + name: "Quality B".to_owned(), + }, + }, + ..ReadarrRelease::default() + }; + let release_c = ReadarrRelease { + protocol: "Protocol C".to_owned(), + age: 3, + title: HorizontallyScrollableText::from("Title C"), + indexer: "Indexer C".to_owned(), + size: 3, + rejected: false, + seeders: None, + quality: QualityWrapper { + quality: Quality { + name: "Quality C".to_owned(), + }, + }, + ..ReadarrRelease::default() + }; + + vec![release_a, release_b, release_c] + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index 8d7292b0..3918ca47 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -13,7 +13,9 @@ mod tests { use crate::handlers::readarr_handlers::library::{LibraryHandler, authors_sorting_options}; use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; - use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS}; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, LIBRARY_BLOCKS, + }; mod test_handle_scroll_up_and_down { use pretty_assertions::assert_str_eq; @@ -878,8 +880,12 @@ mod tests { #[test] fn test_library_handler_accepts() { + let mut library_handler_blocks = Vec::new(); + library_handler_blocks.extend(LIBRARY_BLOCKS); + library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS); + ActiveReadarrBlock::iter().for_each(|readarr_block| { - if LIBRARY_BLOCKS.contains(&readarr_block) { + if library_handler_blocks.contains(&readarr_block) { assert!( LibraryHandler::accepts(readarr_block), "{readarr_block} is not accepted by the LibraryHandler" diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index 61bfbb95..69179113 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -19,6 +19,10 @@ use super::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::models::Route; +mod author_details_handler; + +pub(in crate::handlers::readarr_handlers) use author_details_handler::AuthorDetailsHandler; + #[cfg(test)] #[path = "library_handler_tests.rs"] mod library_handler_tests; @@ -54,12 +58,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, |app| &mut app.data.readarr_data.authors, authors_table_handling_config, ) { - self.handle_key_event(); + match self.active_readarr_block { + _ if AuthorDetailsHandler::accepts(self.active_readarr_block) => { + AuthorDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } + _ => self.handle_key_event(), + } } } fn accepts(active_block: ActiveReadarrBlock) -> bool { - LIBRARY_BLOCKS.contains(&active_block) + AuthorDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 5ffee4ce..477598d5 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -6,6 +6,7 @@ use crate::{ models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, }; +mod history; mod library; #[cfg(test)] From d1d50337105ce3aceaa60c26d0f984f5a233e6fe Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 01:05:43 -0600 Subject: [PATCH 067/170] test(readarr): snapshot the Readarr context row, loading, and empty states The stats, downloads, and logo panes of the Readarr context row had no test exercising them. Add snapshots for the populated, loading, and empty variants of the context row, plus loading and empty variants of the tab view. --- src/ui/readarr_ui/readarr_ui_tests.rs | 62 +++++++++++++++++++ ...tests__readarr_ui_renders_context_row.snap | 54 ++++++++++++++++ ..._readarr_ui_renders_context_row_empty.snap | 54 ++++++++++++++++ ...eadarr_ui_renders_context_row_loading.snap | 54 ++++++++++++++++ ...pshot_tests__readarr_ui_renders_empty.snap | 54 ++++++++++++++++ ...hot_tests__readarr_ui_renders_loading.snap | 54 ++++++++++++++++ 6 files changed, 332 insertions(+) create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_empty.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_loading.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_empty.snap create mode 100644 src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_loading.snap diff --git a/src/ui/readarr_ui/readarr_ui_tests.rs b/src/ui/readarr_ui/readarr_ui_tests.rs index 57caeea3..b6318b11 100644 --- a/src/ui/readarr_ui/readarr_ui_tests.rs +++ b/src/ui/readarr_ui/readarr_ui_tests.rs @@ -20,6 +20,68 @@ mod tests { use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; use rstest::rstest; + #[test] + fn test_readarr_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_context_row() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw_context_row(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_context_row_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw_context_row(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_context_row_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw_context_row(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[rstest] #[case(ActiveReadarrBlock::Authors, 0)] #[case(ActiveReadarrBlock::Downloads, 1)] diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row.snap new file mode 100644 index 00000000..b53029b6 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_empty.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_empty.snap new file mode 100644 index 00000000..1b8ebccf --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_empty.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│ ││ ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│ ││ ││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│ ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│ ││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│ ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│ ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_loading.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_loading.snap new file mode 100644 index 00000000..1cc60a50 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│ ││ ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│ ││ ││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│ Loading ... ││ Loading ... ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│ ││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│ ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│ ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_empty.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_empty.snap new file mode 100644 index 00000000..90973155 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_empty.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_loading.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_loading.snap new file mode 100644 index 00000000..27ac8502 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ │ +│ │ +│ Loading ... │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ From a8d13ae5c8751bae66098dda42bf568e2f67a974 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 01:41:01 -0600 Subject: [PATCH 068/170] feat(readarr): render the author details view Add AuthorDetailsUi: the author description pane plus the three author info tabs (Books, History, and Author Search), along with the history item details, automatic search, update and scan, and manual search confirmation popups. LibraryUi now accepts and draws it over the authors table. The description pane omits the Disambiguation and Type lines that the Lidarr equivalent renders. GET /author returns neither key, so both lines would be blank on every record. The books table drops the Type and Duration columns for the same reason, since books have no type and no duration, and renders Files as the book file count over the total book count. Land create_history_event_details together with this first caller so it is not committed as an unused function. Extract decorate_book_row_with_style rather than inlining the row styling in the table closure, matching decorate_author_row_with_style in the sibling library view. Rendering to a string discards colour, so the styling branches are only reachable by tests through a named function. --- .../readarr_ui/library/author_details_ui.rs | 587 ++++++++++++++++++ .../library/author_details_ui_tests.rs | 299 +++++++++ src/ui/readarr_ui/library/library_ui_tests.rs | 22 +- src/ui/readarr_ui/library/mod.rs | 11 +- ...tests__author_details_AuthorDetails_0.snap | 52 ++ ...author_details_AuthorHistoryDetails_1.snap | 52 ++ ...hor_details_AuthorHistorySortPrompt_1.snap | 52 ++ ...tests__author_details_AuthorHistory_1.snap | 52 ++ ...ils_AutomaticallySearchAuthorPrompt_0.snap | 52 ++ ...ils_AutomaticallySearchAuthorPrompt_1.snap | 52 ++ ...ils_AutomaticallySearchAuthorPrompt_2.snap | 52 ++ ...or_details_FilterAuthorHistoryError_1.snap | 52 ++ ..._author_details_FilterAuthorHistory_1.snap | 52 ++ ...ils_ManualAuthorSearchConfirmPrompt_2.snap | 52 ++ ...etails_ManualAuthorSearchSortPrompt_2.snap | 52 ++ ...__author_details_ManualAuthorSearch_2.snap | 52 ++ ...or_details_SearchAuthorHistoryError_1.snap | 52 ++ ..._author_details_SearchAuthorHistory_1.snap | 52 ++ ...ts__author_details_SearchBooksError_0.snap | 52 ++ ...t_tests__author_details_SearchBooks_0.snap | 52 ++ ...r_details_UpdateAndScanAuthorPrompt_0.snap | 52 ++ ...r_details_UpdateAndScanAuthorPrompt_1.snap | 52 ++ ...r_details_UpdateAndScanAuthorPrompt_2.snap | 52 ++ ...tic_search_prompt_over_author_details.snap | 52 ++ ..._author_details_ui_renders_statistics.snap | 52 ++ ...e_and_scan_prompt_over_author_details.snap | 52 ++ ...s__empty_author_details_AuthorDetails.snap | 52 ++ ...s__empty_author_details_AuthorHistory.snap | 52 ++ ...y_author_details_AuthorHistoryDetails.snap | 52 ++ ...pty_author_details_ManualAuthorSearch.snap | 52 ++ ..._loading_author_details_AuthorDetails.snap | 52 ++ ..._loading_author_details_AuthorHistory.snap | 52 ++ ...ing_author_details_ManualAuthorSearch.snap | 52 ++ ...i_renders_author_details_over_library.snap | 52 ++ src/ui/readarr_ui/mod.rs | 1 + src/ui/readarr_ui/readarr_ui_utils.rs | 164 +++++ src/ui/readarr_ui/readarr_ui_utils_tests.rs | 477 ++++++++++++++ 37 files changed, 3118 insertions(+), 3 deletions(-) create mode 100644 src/ui/readarr_ui/library/author_details_ui.rs create mode 100644 src/ui/readarr_ui/library/author_details_ui_tests.rs create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap create mode 100644 src/ui/readarr_ui/readarr_ui_utils.rs create mode 100644 src/ui/readarr_ui/readarr_ui_utils_tests.rs diff --git a/src/ui/readarr_ui/library/author_details_ui.rs b/src/ui/readarr_ui/library/author_details_ui.rs new file mode 100644 index 00000000..abcbdf04 --- /dev/null +++ b/src/ui/readarr_ui/library/author_details_ui.rs @@ -0,0 +1,587 @@ +use chrono::Utc; +use deunicode::deunicode; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; +use regex::Regex; + +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, +}; +use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +use crate::ui::utils::decorate_peer_style; +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::{DrawUi, draw_popup, draw_tabs}; +use crate::utils::convert_to_gb; +use ratatui::layout::Alignment; +use ratatui::text::Text; +use serde_json::Number; + +#[cfg(test)] +#[path = "author_details_ui_tests.rs"] +mod author_details_ui_tests; + +pub(super) struct AuthorDetailsUi; + +impl DrawUi for AuthorDetailsUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + let route = app.get_current_route(); + if let Route::Readarr(active_readarr_block, _) = route { + let draw_author_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + f.render_widget( + title_block( + &app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + ), + popup_area, + ); + let [description_area, detail_area] = + Layout::vertical([Constraint::Length(12), Constraint::Fill(0)]) + .margin(1) + .areas(popup_area); + draw_author_description(f, app, description_area); + let content_area = draw_tabs( + f, + detail_area, + "Author Details", + &app.data.readarr_data.author_info_tabs, + ); + draw_author_details(f, app, content_area); + + match active_readarr_block { + ActiveReadarrBlock::AuthorHistoryDetails => { + draw_author_history_item_details_popup(f, app); + } + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt => { + let prompt = format!( + "Do you want to trigger an automatic search of your indexers for all monitored book(s) for the author: {}?", + app + .data + .readarr_data + .authors + .current_selection() + .author_name + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Automatic Author Search") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::UpdateAndScanAuthorPrompt => { + let prompt = format!( + "Do you want to trigger an update and disk scan for the author: {}?", + app + .data + .readarr_data + .authors + .current_selection() + .author_name + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update and Scan") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + draw_manual_author_search_confirm_prompt(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_author_details_popup, Size::XXLarge); + } + } +} + +fn draw_author_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = app.data.readarr_data.authors.current_selection(); + let monitored = if current_selection.monitored { + "Yes" + } else { + "No" + }; + let quality_profile = app + .data + .readarr_data + .quality_profile_map + .get_by_left(¤t_selection.quality_profile_id) + .cloned() + .unwrap_or_default(); + let metadata_profile = app + .data + .readarr_data + .metadata_profile_map + .get_by_left(¤t_selection.metadata_profile_id) + .cloned() + .unwrap_or_default(); + 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 author_description = vec![ + Line::from(vec![ + "Author: ".primary().bold(), + current_selection.author_name.text.clone().primary().bold(), + ]), + Line::from(vec![ + "Overview: ".primary().bold(), + overview.default_color(), + ]), + Line::from(vec![ + "Status: ".primary().bold(), + current_selection.status.to_display_str().default_color(), + ]), + Line::from(vec![ + "Genres: ".primary().bold(), + current_selection.genres.join(", ").default_color(), + ]), + Line::from(vec![ + "Rating: ".primary().bold(), + current_selection + .ratings + .as_ref() + .map_or_else( + || "N/A".to_owned(), + |r| format!("{}%", (r.value * 10.0) as i32), + ) + .default_color(), + ]), + Line::from(vec![ + "Path: ".primary().bold(), + current_selection.path.clone().default_color(), + ]), + Line::from(vec![ + "Quality Profile: ".primary().bold(), + quality_profile.default_color(), + ]), + Line::from(vec![ + "Metadata Profile: ".primary().bold(), + metadata_profile.default_color(), + ]), + Line::from(vec![ + "Monitored: ".primary().bold(), + monitored.default_color(), + ]), + ]; + + if let Some(stats) = current_selection.statistics.as_ref() { + let size = convert_to_gb(stats.size_on_disk); + author_description.extend(vec![ + Line::from(vec![ + "Books: ".primary().bold(), + stats.book_count.to_string().default_color(), + ]), + Line::from(vec![ + "Files: ".primary().bold(), + format!("{}/{}", stats.book_file_count, stats.total_book_count).default_color(), + ]), + Line::from(vec![ + "Size on Disk: ".primary().bold(), + format!("{size:.2} GB").default_color(), + ]), + ]); + } + + let description_paragraph = Paragraph::new(author_description) + .block(borderless_block()) + .wrap(Wrap { trim: true }); + f.render_widget(description_paragraph, area); +} + +fn draw_author_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = + app.data.readarr_data.author_info_tabs.get_active_route() + { + match active_readarr_block { + ActiveReadarrBlock::AuthorDetails => draw_books_table(f, app, area), + ActiveReadarrBlock::AuthorHistory => draw_author_history_table(f, app, area), + ActiveReadarrBlock::ManualAuthorSearch => draw_author_releases(f, app, area), + _ => (), + } + } +} + +fn draw_books_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let current_selection = if app.data.readarr_data.books.is_empty() { + Book::default() + } else { + app.data.readarr_data.books.current_selection().clone() + }; + let content = Some(&mut app.data.readarr_data.books); + let book_row_mapping = |book: &Book| { + book.title.scroll_left_or_reset( + get_width_from_percentage(area, 38), + *book == current_selection, + app.should_text_scroll, + ); + let monitored = if book.monitored { "🏷" } else { "" }; + let release_date = book + .release_date + .map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string()); + let book_file_count = book.statistics.as_ref().map_or_else( + || "0/0".to_owned(), + |s| format!("{}/{}", s.book_file_count, s.total_book_count), + ); + let size = book + .statistics + .as_ref() + .map_or(0f64, |s| convert_to_gb(s.size_on_disk)); + + decorate_book_row_with_style( + book, + Row::new(vec![ + Cell::from(monitored.to_owned()), + Cell::from(book.title.to_string()), + Cell::from(book_file_count), + Cell::from(release_date), + Cell::from(format!("{size:.2} GB")), + ]), + ) + }; + + let is_searching = active_readarr_block == ActiveReadarrBlock::SearchBooks; + let book_table = ManagarrTable::new(content, book_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .searching(is_searching) + .search_produced_empty_results(active_readarr_block == ActiveReadarrBlock::SearchBooksError) + .headers(["Monitored", "Title", "Files", "Release Date", "Size"]) + .constraints([ + Constraint::Percentage(10), + Constraint::Percentage(40), + Constraint::Percentage(15), + Constraint::Percentage(17), + Constraint::Percentage(18), + ]); + + if is_searching { + book_table.show_cursor(f, area); + } + + f.render_widget(book_table, area); + } +} + +fn decorate_book_row_with_style<'a>(book: &Book, row: Row<'a>) -> Row<'a> { + if !book.monitored { + row.unmonitored() + } else if let Some(stats) = book.statistics.as_ref() { + if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 { + row.downloaded() + } else if let Some(release_date) = book.release_date.as_ref() { + if release_date > &Utc::now() { + row.unreleased() + } else { + row.missing() + } + } else { + row.missing() + } + } else { + row.indeterminate() + } +} + +fn draw_author_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if !app.is_loading { + let current_selection = if app.data.readarr_data.author_history.is_empty() { + ReadarrHistoryItem::default() + } else { + app + .data + .readarr_data + .author_history + .current_selection() + .clone() + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &ReadarrHistoryItem| { + let ReadarrHistoryItem { + source_title, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 40), + current_selection == *history_item, + app.should_text_scroll, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let history_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.author_history), + history_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::AuthorHistorySortPrompt) + .searching(active_readarr_block == ActiveReadarrBlock::SearchAuthorHistory) + .search_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::SearchAuthorHistoryError, + ) + .filtering(active_readarr_block == ActiveReadarrBlock::FilterAuthorHistory) + .filter_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::FilterAuthorHistoryError, + ) + .headers(["Source Title", "Event Type", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(15), + Constraint::Percentage(25), + ]); + + if [ + ActiveReadarrBlock::SearchAuthorHistory, + ActiveReadarrBlock::FilterAuthorHistory, + ] + .contains(&active_readarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } + } else { + f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.books.is_empty(), + layout_block_top_border(), + ), + area, + ); + } +} + +fn draw_author_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.readarr_data.author_history.is_empty() { + ReadarrHistoryItem::default() + } else { + app + .data + .readarr_data + .author_history + .current_selection() + .clone() + }; + + let line_vec = create_history_event_details(current_selection); + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowLongMessage), f.area()); +} + +fn draw_author_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let (current_selection, is_empty) = if app.data.readarr_data.author_releases.is_empty() { + (ReadarrRelease::default(), true) + } else { + ( + app + .data + .readarr_data + .author_releases + .current_selection() + .clone(), + app.data.readarr_data.author_releases.is_empty(), + ) + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let release_row_mapping = |release: &ReadarrRelease| { + let ReadarrRelease { + protocol, + age, + title, + indexer, + size, + rejected, + seeders, + leechers, + quality, + .. + } = release; + + let age = format!("{age} days"); + title.scroll_left_or_reset( + get_width_from_percentage(area, 35), + current_selection == *release + && active_readarr_block != ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + app.should_text_scroll, + ); + 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_or(Number::from(0u64)) + .as_u64() + .unwrap_or_default(); + let leechers = leechers + .clone() + .unwrap_or(Number::from(0u64)) + .as_u64() + .unwrap_or_default(); + + decorate_peer_style( + seeders, + leechers, + Text::from(format!("{seeders} / {leechers}")), + ) + }; + + let quality_name = 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(quality_name), + ]) + .primary() + }; + let author_release_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.author_releases), + release_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading || is_empty) + .sorting(active_readarr_block == ActiveReadarrBlock::ManualAuthorSearchSortPrompt) + .headers([ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Quality", + ]) + .constraints([ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(5), + Constraint::Percentage(35), + Constraint::Percentage(15), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Percentage(10), + ]); + + f.render_widget(author_release_table, area); + } +} + +fn draw_manual_author_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = app.data.readarr_data.author_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: {}?", + current_selection.title.text + ) + } else { + format!( + "Do you want to download the release: {}?", + current_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.readarr_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.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} diff --git a/src/ui/readarr_ui/library/author_details_ui_tests.rs b/src/ui/readarr_ui/library/author_details_ui_tests.rs new file mode 100644 index 00000000..7532a510 --- /dev/null +++ b/src/ui/readarr_ui/library/author_details_ui_tests.rs @@ -0,0 +1,299 @@ +#[cfg(test)] +mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + use strum::IntoEnumIterator; + + use crate::models::readarr_models::{Book, BookStatistics}; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::author_details_ui::{ + AuthorDetailsUi, decorate_book_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; + + #[test] + fn test_author_details_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!(AuthorDetailsUi::accepts(active_readarr_block.into())); + } else { + assert!(!AuthorDetailsUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_decorate_book_row_with_style_unmonitored() { + let book = Book::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_book_row_with_style_downloaded_when_all_files_present() { + let book = Book { + monitored: true, + statistics: Some(BookStatistics { + book_file_count: 3, + total_book_count: 3, + ..BookStatistics::default() + }), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_book_row_with_style_unreleased_when_files_are_missing_and_release_date_is_future() + { + let book = Book { + monitored: true, + release_date: Some(Utc::now() + Duration::days(1)), + statistics: Some(BookStatistics { + book_file_count: 0, + total_book_count: 3, + ..BookStatistics::default() + }), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_book_row_with_style_missing_when_files_are_missing_and_release_date_has_passed() + { + let book = Book { + monitored: true, + release_date: Some(Utc::now() - Duration::days(1)), + statistics: Some(BookStatistics { + book_file_count: 1, + total_book_count: 3, + ..BookStatistics::default() + }), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_book_row_with_style_missing_when_files_are_missing_and_no_release_date() { + let book = Book { + monitored: true, + release_date: None, + statistics: Some(BookStatistics { + book_file_count: 1, + total_book_count: 3, + ..BookStatistics::default() + }), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_book_row_with_style_missing_when_total_book_count_is_zero() { + let book = Book { + monitored: true, + release_date: Some(Utc::now() - Duration::days(1)), + statistics: Some(BookStatistics::default()), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_book_row_with_style_indeterminate_when_no_statistics() { + let book = Book { + monitored: true, + statistics: None, + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + mod snapshot_tests { + use rstest::rstest; + + use crate::app::App; + use crate::models::readarr_models::{AuthorStatistics, BookStatistics}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::StatefulTable; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::author_details_ui::AuthorDetailsUi; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + #[rstest] + #[case(ActiveReadarrBlock::AuthorDetails, 0)] + #[case(ActiveReadarrBlock::AuthorHistory, 1)] + #[case(ActiveReadarrBlock::ManualAuthorSearch, 2)] + #[case(ActiveReadarrBlock::SearchBooks, 0)] + #[case(ActiveReadarrBlock::SearchBooksError, 0)] + #[case(ActiveReadarrBlock::UpdateAndScanAuthorPrompt, 0)] + #[case(ActiveReadarrBlock::UpdateAndScanAuthorPrompt, 1)] + #[case(ActiveReadarrBlock::UpdateAndScanAuthorPrompt, 2)] + #[case(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, 0)] + #[case(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, 1)] + #[case(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, 2)] + #[case(ActiveReadarrBlock::SearchAuthorHistory, 1)] + #[case(ActiveReadarrBlock::SearchAuthorHistoryError, 1)] + #[case(ActiveReadarrBlock::FilterAuthorHistory, 1)] + #[case(ActiveReadarrBlock::FilterAuthorHistoryError, 1)] + #[case(ActiveReadarrBlock::AuthorHistorySortPrompt, 1)] + #[case(ActiveReadarrBlock::AuthorHistoryDetails, 1)] + #[case(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, 2)] + #[case(ActiveReadarrBlock::ManualAuthorSearchSortPrompt, 2)] + fn test_author_details_ui_renders( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.author_info_tabs.set_index(index); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("author_details_{active_readarr_block}_{index}"), + output + ); + } + + #[rstest] + #[case(ActiveReadarrBlock::AuthorDetails, 0)] + #[case(ActiveReadarrBlock::AuthorHistory, 1)] + #[case(ActiveReadarrBlock::ManualAuthorSearch, 2)] + fn test_author_details_ui_renders_author_details_loading( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.author_info_tabs.set_index(index); + app.is_loading = true; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("loading_author_details_{active_readarr_block}"), + output + ); + } + + #[rstest] + #[case(ActiveReadarrBlock::AuthorDetails, 0)] + #[case(ActiveReadarrBlock::AuthorHistory, 1)] + #[case(ActiveReadarrBlock::AuthorHistoryDetails, 1)] + #[case(ActiveReadarrBlock::ManualAuthorSearch, 2)] + fn test_author_details_ui_renders_author_details_empty( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.books = StatefulTable::default(); + app.data.readarr_data.author_releases = StatefulTable::default(); + app.data.readarr_data.author_history = StatefulTable::default(); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.author_info_tabs.set_index(index); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("empty_author_details_{active_readarr_block}"), + output + ); + } + + #[test] + fn test_author_details_ui_renders_update_and_scan_prompt_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_details_ui_renders_automatic_search_prompt_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_details_ui_renders_statistics() { + let mut app = App::test_default_fully_populated(); + let mut author = app.data.readarr_data.authors.current_selection().clone(); + author.statistics = Some(AuthorStatistics { + book_count: 3, + book_file_count: 2, + total_book_count: 3, + available_book_count: 2, + size_on_disk: 3543348019, + percent_of_books: 66.6, + }); + app.data.readarr_data.authors.set_items(vec![author]); + let mut book = app.data.readarr_data.books.current_selection().clone(); + book.statistics = Some(BookStatistics { + book_file_count: 1, + total_book_count: 1, + size_on_disk: 1771674009, + percent_of_books: 100.0, + }); + app.data.readarr_data.books.set_items(vec![book]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs index 65f7e839..3714711e 100644 --- a/src/ui/readarr_ui/library/library_ui_tests.rs +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -3,7 +3,9 @@ mod tests { use strum::IntoEnumIterator; use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; - use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS}; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, LIBRARY_BLOCKS, + }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; use crate::ui::styles::ManagarrStyle; @@ -12,8 +14,11 @@ mod tests { #[test] fn test_library_ui_accepts() { + let mut blocks = LIBRARY_BLOCKS.to_vec(); + blocks.extend(AUTHOR_DETAILS_BLOCKS); + for active_readarr_block in ActiveReadarrBlock::iter() { - if LIBRARY_BLOCKS.contains(&active_readarr_block) { + if blocks.contains(&active_readarr_block) { assert!( LibraryUi::accepts(active_readarr_block.into()), "{active_readarr_block} is not accepted by the LibraryUi" @@ -231,5 +236,18 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_library_ui_renders_author_details_over_library() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.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/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index 1e1730f4..ef0798dd 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -1,3 +1,4 @@ +use author_details_ui::AuthorDetailsUi; use ratatui::{ Frame, layout::{Constraint, Rect}, @@ -20,6 +21,8 @@ use crate::{ }, }; +mod author_details_ui; + #[cfg(test)] #[path = "library_ui_tests.rs"] mod library_ui_tests; @@ -29,14 +32,20 @@ pub(super) struct LibraryUi; impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Readarr(active_readarr_block, _) = route { - return LIBRARY_BLOCKS.contains(&active_readarr_block); + return AuthorDetailsUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_readarr_block); } false } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); draw_library(f, app, area); + + match route { + _ if AuthorDetailsUi::accepts(route) => AuthorDetailsUi::draw(f, app, area), + _ => (), + } } } diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap new file mode 100644 index 00000000..4b6e0d8a --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap new file mode 100644 index 00000000..2f5a2119 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ─────────────╭─────────────────────────────────── Details ───────────────────────────────────╮───────────────────────────────╮│ + ││ Books │ History │ Author Searc│Source Title: Test Source Title │ ││ + ││───────────────────────────────│Event Type: grabbed │───────────────────────────────││ + ││ Source Title ▼ │Quality: EPUB │ ││ + ││=> Test Source Title │Date: 2023-05-20 21:29:16 UTC │-05-20 21:29:16 UTC ││ + ││ │Indexer: │ ││ + ││ │NZB Info URL: │ ││ + ││ │Release Group: │ ││ + ││ │Age: 0 days │ ││ + ││ │Published Date: 1970-01-01 00:00:00 UTC │ ││ + ││ │Download Client: │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰─────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap new file mode 100644 index 00000000..49abd4b8 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭───────────────────────────╮ ││ + ││ │Something │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰───────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap new file mode 100644 index 00000000..4d105d17 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap new file mode 100644 index 00000000..6e9a1615 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ + ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap new file mode 100644 index 00000000..a8436012 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ + ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ + ││ Source Title ▼ │ │ Date ││ + ││=> Test Source Title │ │ 2023-05-20 21:29:16 UTC ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap new file mode 100644 index 00000000..5c5c8b4d --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ + ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ + ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torren│ │ 0.0 GB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet│ │ 0.0 GB EPUB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap new file mode 100644 index 00000000..182f53b7 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭──────────── Error ─────────────╮ ││ + ││ │ The given filter produced empty │ ││ + ││ ╰──────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap new file mode 100644 index 00000000..dea5d6cf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭────────────── Filter ───────────────╮ ││ + ││ │author history filter │ ││ + ││ ╰───────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap new file mode 100644 index 00000000..8b0627a4 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ ╭───────────────── Download Rejected Release ──────────────────╮ │ + │╭ Author Details ──────────────────────│ Do you really want to download the rejected release: Test │───────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Torrent Release? │ ││ + ││────────────────────────────────────────│ │───────────────────────────────────────││ + ││ Source ▼ Age ⛔ Title │ │e Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torre│Rejection reasons: │ GB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usene│• Unknown quality profile │ GB EPUB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭──────────────────────────────╮╭──────────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰──────────────────────────────╯╰──────────────────────────────╯│ ││ + ││ ╰────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap new file mode 100644 index 00000000..f2a03272 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Age ⛔ Title Indexer Size Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭───────────────────────────╮ ││ + ││ │Something │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰───────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap new file mode 100644 index 00000000..2f8dacfc --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source ▼ Age ⛔ Title Indexer Size Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap new file mode 100644 index 00000000..511ff704 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭──────────── Error ─────────────╮ ││ + ││ │ No items found matching search │ ││ + ││ ╰──────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap new file mode 100644 index 00000000..74c8b861 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭────────────── Search ───────────────╮ ││ + ││ │author history search │ ││ + ││ ╰───────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap new file mode 100644 index 00000000..6d8acb06 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭──────────── Error ─────────────╮ ││ + ││ │ No items found matching search │ ││ + ││ ╰──────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap new file mode 100644 index 00000000..66bac5cf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭────────────── Search ───────────────╮ ││ + ││ │book search │ ││ + ││ ╰───────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap new file mode 100644 index 00000000..0c349acf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ + ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap new file mode 100644 index 00000000..85ec2084 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ + ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ + ││ Source Title ▼ │ │ Date ││ + ││=> Test Source Title │ │ 2023-05-20 21:29:16 UTC ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap new file mode 100644 index 00000000..6cb37307 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ + ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ + ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torren│ │ 0.0 GB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet│ │ 0.0 GB EPUB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap new file mode 100644 index 00000000..6e9a1615 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ + ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap new file mode 100644 index 00000000..ac285c3a --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │Books: 3 │ + │Files: 2/3 │ + │Size on Disk: 3.30 GB │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 1/1 2023-05-20 1.65 GB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap new file mode 100644 index 00000000..0c349acf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ + ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap new file mode 100644 index 00000000..96083953 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap new file mode 100644 index 00000000..96083953 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap new file mode 100644 index 00000000..a4cfb206 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ─────────────╭─────────────────────────────────── Details ───────────────────────────────────╮───────────────────────────────╮│ + ││ Books │ History │ Author Searc│Source Title: │ ││ + ││───────────────────────────────│Event Type: unknown │───────────────────────────────││ + ││ │Quality: │ ││ + ││ │Date: 1970-01-01 00:00:00 UTC │ ││ + ││ │No additional details available. │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰─────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap new file mode 100644 index 00000000..5c0ea0b1 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ Loading ... ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap new file mode 100644 index 00000000..5c0ea0b1 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ Loading ... ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap new file mode 100644 index 00000000..5c0ea0b1 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ Loading ... ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap new file mode 100644 index 00000000..5c0ea0b1 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ Loading ... ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap new file mode 100644 index 00000000..d7757101 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs index 643b9cce..063f50f3 100644 --- a/src/ui/readarr_ui/mod.rs +++ b/src/ui/readarr_ui/mod.rs @@ -35,6 +35,7 @@ use crate::{ }; mod library; +mod readarr_ui_utils; #[cfg(test)] #[path = "readarr_ui_tests.rs"] diff --git a/src/ui/readarr_ui/readarr_ui_utils.rs b/src/ui/readarr_ui/readarr_ui_utils.rs new file mode 100644 index 00000000..4d3e3ad4 --- /dev/null +++ b/src/ui/readarr_ui/readarr_ui_utils.rs @@ -0,0 +1,164 @@ +use crate::models::readarr_models::{ + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, +}; +use ratatui::text::Line; + +#[cfg(test)] +#[path = "readarr_ui_utils_tests.rs"] +mod readarr_ui_utils_tests; + +pub(super) fn create_history_event_details(history_item: ReadarrHistoryItem) -> Vec> { + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item; + let ReadarrHistoryData { + indexer, + nzb_info_url, + release_group, + age, + published_date, + download_client_name, + download_client, + message, + reason, + dropped_path, + imported_path, + source_path, + path, + status_messages, + } = data; + + let mut lines = vec![ + Line::from(format!("Source Title: {}", source_title.text.trim_start())), + Line::from(format!("Event Type: {event_type}")), + Line::from(format!("Quality: {}", quality.quality.name.trim_start())), + Line::from(format!("Date: {date}")), + ]; + + match event_type { + ReadarrHistoryEventType::Grabbed => { + lines.push(Line::from(format!( + "Indexer: {}", + indexer.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "NZB Info URL: {}", + nzb_info_url.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Age: {} days", + age.unwrap_or("0".to_owned()).trim_start() + ))); + lines.push(Line::from(format!( + "Published Date: {}", + published_date.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Download Client: {}", + download_client_name + .unwrap_or(download_client.unwrap_or_default()) + .trim_start() + ))); + } + ReadarrHistoryEventType::DownloadImported => { + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::DownloadFailed => { + lines.push(Line::from(format!( + "Download Client: {}", + download_client_name + .unwrap_or(download_client.unwrap_or_default()) + .trim_start() + ))); + lines.push(Line::from(format!( + "Message: {}", + message.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Indexer: {}", + indexer.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookFileDeleted => { + lines.push(Line::from(format!( + "Reason: {}", + reason.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookFileImported => { + lines.push(Line::from(format!( + "Dropped Path: {}", + dropped_path.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Imported Path: {}", + imported_path.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Download Client: {}", + download_client_name + .unwrap_or(download_client.unwrap_or_default()) + .trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookFileRenamed => { + lines.push(Line::from(format!( + "Source Path: {}", + source_path.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Path: {}", + path.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookFileRetagged => { + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookImportIncomplete => { + lines.push(Line::from(format!( + "Status Messages: {}", + status_messages.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + _ => { + lines.push(Line::from("No additional details available.".to_owned())); + } + } + + lines +} diff --git a/src/ui/readarr_ui/readarr_ui_utils_tests.rs b/src/ui/readarr_ui/readarr_ui_utils_tests.rs new file mode 100644 index 00000000..1b4fd3f9 --- /dev/null +++ b/src/ui/readarr_ui/readarr_ui_utils_tests.rs @@ -0,0 +1,477 @@ +#[cfg(test)] +mod tests { + use chrono::Utc; + use pretty_assertions::assert_eq; + use ratatui::text::Line; + + use crate::models::readarr_models::{ + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; + + #[test] + fn test_create_history_event_details_grabbed() { + let history_item = readarr_history_item(ReadarrHistoryEventType::Grabbed); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Indexer: {}", data.indexer.unwrap().trim_start())) + ); + assert_eq!( + result[5], + Line::from(format!( + "NZB Info URL: {}", + data.nzb_info_url.unwrap().trim_start() + )) + ); + assert_eq!( + result[6], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!( + result[7], + Line::from(format!("Age: {} days", data.age.unwrap().trim_start())) + ); + assert_eq!( + result[8], + Line::from(format!("Published Date: {}", data.published_date.unwrap())) + ); + assert_eq!( + result[9], + Line::from(format!( + "Download Client: {}", + data.download_client_name.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 10); + } + + #[test] + fn test_create_history_event_details_grabbed_uses_download_client_as_fallback() { + let mut history_item = readarr_history_item(ReadarrHistoryEventType::Grabbed); + history_item.data.download_client_name = None; + history_item.data.download_client = Some("\nFallback Client".to_owned()); + + let result = create_history_event_details(history_item); + + assert_eq!(result[9], Line::from("Download Client: Fallback Client")); + } + + #[test] + fn test_create_history_event_details_download_imported() { + let history_item = readarr_history_item(ReadarrHistoryEventType::DownloadImported); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_download_failed() { + let history_item = readarr_history_item(ReadarrHistoryEventType::DownloadFailed); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Download Client: {}", + data.download_client_name.unwrap().trim_start() + )) + ); + assert_eq!( + result[5], + Line::from(format!("Message: {}", data.message.unwrap().trim_start())) + ); + assert_eq!( + result[6], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!( + result[7], + Line::from(format!("Indexer: {}", data.indexer.unwrap().trim_start())) + ); + assert_eq!(result.len(), 8); + } + + #[test] + fn test_create_history_event_details_book_file_deleted() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookFileDeleted); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Reason: {}", data.reason.unwrap().trim_start())) + ); + assert_eq!( + result[5], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 6); + } + + #[test] + fn test_create_history_event_details_book_file_imported() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookFileImported); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Dropped Path: {}", + data.dropped_path.unwrap().trim_start() + )) + ); + assert_eq!( + result[5], + Line::from(format!( + "Imported Path: {}", + data.imported_path.unwrap().trim_start() + )) + ); + assert_eq!( + result[6], + Line::from(format!( + "Download Client: {}", + data.download_client_name.unwrap().trim_start() + )) + ); + assert_eq!( + result[7], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 8); + } + + #[test] + fn test_create_history_event_details_book_file_renamed() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookFileRenamed); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Source Path: {}", + data.source_path.unwrap().trim_start() + )) + ); + assert_eq!( + result[5], + Line::from(format!("Path: {}", data.path.unwrap().trim_start())) + ); + assert_eq!( + result[6], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 7); + } + + #[test] + fn test_create_history_event_details_book_file_retagged() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookFileRetagged); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_book_import_incomplete() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookImportIncomplete); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Status Messages: {}", + data.status_messages.unwrap().trim_start() + )) + ); + assert_eq!( + result[5], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 6); + } + + #[test] + fn test_create_history_event_details_unknown() { + let history_item = readarr_history_item(ReadarrHistoryEventType::Unknown); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!(result[4], Line::from("No additional details available.")); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_author_folder_imported_has_no_additional_details() { + let history_item = readarr_history_item(ReadarrHistoryEventType::AuthorFolderImported); + + let result = create_history_event_details(history_item); + + assert_eq!(result[4], Line::from("No additional details available.")); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_download_ignored_has_no_additional_details() { + let history_item = readarr_history_item(ReadarrHistoryEventType::DownloadIgnored); + + let result = create_history_event_details(history_item); + + assert_eq!(result[4], Line::from("No additional details available.")); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_with_empty_optional_fields() { + let mut history_item = readarr_history_item(ReadarrHistoryEventType::Grabbed); + history_item.data = ReadarrHistoryData::default(); + + let result = create_history_event_details(history_item); + + assert_eq!(result[4], Line::from("Indexer: ")); + assert_eq!(result[5], Line::from("NZB Info URL: ")); + assert_eq!(result[6], Line::from("Release Group: ")); + assert_eq!(result[7], Line::from("Age: 0 days")); + assert!(result[8].to_string().starts_with("Published Date:")); + assert_eq!(result[9], Line::from("Download Client: ")); + } + + fn readarr_history_item(event_type: ReadarrHistoryEventType) -> ReadarrHistoryItem { + ReadarrHistoryItem { + id: 1, + source_title: "\nTest Book - Author Name".into(), + author_id: 10, + book_id: 100, + event_type, + quality: QualityWrapper { + quality: Quality { + name: "\nEPUB".to_owned(), + }, + }, + date: Utc::now(), + data: readarr_history_data(), + } + } + + fn readarr_history_data() -> ReadarrHistoryData { + ReadarrHistoryData { + indexer: Some("\nTest Indexer".to_owned()), + release_group: Some("\nTest Release Group".to_owned()), + nzb_info_url: Some("\nhttps://test.url".to_owned()), + download_client_name: Some("\nTest Download Client".to_owned()), + download_client: Some("\nFallback Download Client".to_owned()), + age: Some("\n7".to_owned()), + published_date: Some(Utc::now()), + message: Some("\nTest failure message".to_owned()), + reason: Some("\nTest deletion reason".to_owned()), + dropped_path: Some("\n/downloads/completed/book".to_owned()), + imported_path: Some("\n/books/author/book".to_owned()), + source_path: Some("\n/books/author/old_book_name".to_owned()), + path: Some("\n/books/author/new_book_name".to_owned()), + status_messages: Some("\nMissing chapters: 1, 2, 3".to_owned()), + } + } +} From 5480514b12cc93f27c28827716dd95dc399efbb6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 01:46:44 -0600 Subject: [PATCH 069/170] fix(readarr): drop the always-empty author type column from the authors table --- src/ui/readarr_ui/library/mod.rs | 6 +----- ...sts__library_ui_renders_author_details_over_library.snap | 2 +- ...sts__tests__snapshot_tests__readarr_library_Authors.snap | 2 +- ...__snapshot_tests__readarr_library_AuthorsSortPrompt.snap | 2 +- ...ests__snapshot_tests__readarr_library_FilterAuthors.snap | 2 +- ..._snapshot_tests__readarr_library_FilterAuthorsError.snap | 2 +- ...ests__snapshot_tests__readarr_library_SearchAuthors.snap | 2 +- ..._snapshot_tests__readarr_library_SearchAuthorsError.snap | 2 +- ..._tests__tests__snapshot_tests__readarr_tabs_Authors.snap | 2 +- 9 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/ui/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index ef0798dd..cb0f901d 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -68,7 +68,6 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { app.should_text_scroll, ); let monitored = if author.monitored { "🏷" } else { "" }; - let author_type = author.author_type.clone().unwrap_or_default(); let size = author .statistics .as_ref() @@ -99,7 +98,6 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { author, Row::new(vec![ Cell::from(author.author_name.to_string()), - Cell::from(author_type), Cell::from(author.status.to_display_str()), Cell::from(quality_profile), Cell::from(metadata_profile), @@ -120,7 +118,6 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .filter_produced_empty_results(active_readarr_block == ActiveReadarrBlock::FilterAuthorsError) .headers([ "Name", - "Type", "Status", "Quality Profile", "Metadata Profile", @@ -130,8 +127,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { "Tags", ]) .constraints([ - Constraint::Percentage(25), - Constraint::Percentage(9), + Constraint::Percentage(34), Constraint::Percentage(8), Constraint::Percentage(13), Constraint::Percentage(13), diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap index d7757101..d19b01b3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -3,7 +3,7 @@ source: src/ui/readarr_ui/library/library_ui_tests.rs expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags => Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap index 75b980f2..235492ff 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap @@ -3,5 +3,5 @@ source: src/ui/readarr_ui/library/library_ui_tests.rs expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags => Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap index 4a9fb66d..63f8420f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap @@ -3,7 +3,7 @@ source: src/ui/readarr_ui/library/library_ui_tests.rs expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - Name Type Status Quality Profile Metadata Profile Books Size Monitored Tags + Name Status Quality Profile Metadata Profile Books Size Monitored Tags => Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap index e45676e5..c1fd94a0 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap @@ -3,7 +3,7 @@ source: src/ui/readarr_ui/library/library_ui_tests.rs expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags => Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap index 7380fa4f..59d973b7 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap @@ -3,7 +3,7 @@ source: src/ui/readarr_ui/library/library_ui_tests.rs expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags => Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap index 8fa53ca2..d3e05746 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap @@ -3,7 +3,7 @@ source: src/ui/readarr_ui/library/library_ui_tests.rs expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags => Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap index 7a519870..f4d36c07 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap @@ -3,7 +3,7 @@ source: src/ui/readarr_ui/library/library_ui_tests.rs expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags => Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap index 62866bc1..5b8d5494 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap @@ -5,7 +5,7 @@ expression: output ╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ -│ Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ │=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ │ │ │ │ From baf74d95e13e111afc691861cff073cb8ee33b22 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 01:49:28 -0600 Subject: [PATCH 070/170] fix(readarr): render the update all authors confirmation prompt --- src/ui/readarr_ui/library/library_ui_tests.rs | 13 +++++++ src/ui/readarr_ui/library/mod.rs | 15 ++++++++ ..._ui_renders_update_all_authors_prompt.snap | 38 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs index 3714711e..2f51fddf 100644 --- a/src/ui/readarr_ui/library/library_ui_tests.rs +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -237,6 +237,19 @@ mod tests { insta::assert_snapshot!(output); } + #[test] + fn test_library_ui_renders_update_all_authors_prompt() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_library_ui_renders_author_details_over_library() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index cb0f901d..49190511 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_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, @@ -44,6 +48,17 @@ impl DrawUi for LibraryUi { match route { _ if AuthorDetailsUi::accepts(route) => AuthorDetailsUi::draw(f, app, area), + Route::Readarr(ActiveReadarrBlock::UpdateAllAuthorsPrompt, _) => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update All Authors") + .prompt("Do you want to update info and scan your disks for all of your authors?") + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } _ => (), } } diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap new file mode 100644 index 00000000..ced04cc7 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + + + + + + + + + + + + + + ╭────────────────── Update All Authors ───────────────────╮ + │ Do you want to update info and scan your disks for all of │ + │ your authors? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ From 14deed6b813bed5aa45a8c017c15c5931627b741 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 11:43:49 -0600 Subject: [PATCH 071/170] feat(readarr): handle the book details tabs and open the edition details popup Add BookDetailsHandler, covering the editions, history and manual search tables, the automatic search, manual download and delete book file prompts, and tab movement across the book details modal. Selecting an edition builds the EditionDetailsModal client side from the already loaded row and pushes the popup block. Readarr has no edition detail endpoint, so there is no network request and no event to dispatch. The delete book file prompt is guarded on the book files table being populated. Unlike Lidarr, where a track row carries its own file id, an edition carries none and the file id comes from a separate table that can be empty while editions are present. --- .../library/author_details_handler.rs | 3 +- .../library/book_details_handler.rs | 492 ++++++++ .../library/book_details_handler_tests.rs | 1084 +++++++++++++++++ .../library/library_handler_tests.rs | 13 +- src/handlers/readarr_handlers/library/mod.rs | 13 +- 5 files changed, 1601 insertions(+), 4 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/book_details_handler.rs create mode 100644 src/handlers/readarr_handlers/library/book_details_handler_tests.rs diff --git a/src/handlers/readarr_handlers/library/author_details_handler.rs b/src/handlers/readarr_handlers/library/author_details_handler.rs index d10758a8..26fa82a8 100644 --- a/src/handlers/readarr_handlers/library/author_details_handler.rs +++ b/src/handlers/readarr_handlers/library/author_details_handler.rs @@ -389,7 +389,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorDetailsHandle } } -fn releases_sorting_options() -> Vec> { +pub(in crate::handlers::readarr_handlers::library) fn releases_sorting_options() +-> Vec> { vec![ SortOption { name: "Source", diff --git a/src/handlers/readarr_handlers/library/book_details_handler.rs b/src/handlers/readarr_handlers/library/book_details_handler.rs new file mode 100644 index 00000000..ee16c610 --- /dev/null +++ b/src/handlers/readarr_handlers/library/book_details_handler.rs @@ -0,0 +1,492 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::history::history_sorting_options; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::readarr_models::{Edition, Ratings, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::modals::EditionDetailsModal; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BOOK_DETAILS_BLOCKS}; +use crate::models::servarr_models::ReleaseDownloadBody; +use crate::models::{Route, ScrollableText}; +use crate::network::readarr_network::ReadarrEvent; +use indoc::formatdoc; + +use super::author_details_handler::releases_sorting_options; + +#[cfg(test)] +#[path = "book_details_handler_tests.rs"] +mod book_details_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct BookDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl BookDetailsHandler<'_, '_> { + fn extract_book_id(&self) -> i64 { + self.app.data.readarr_data.books.current_selection().id + } + + fn extract_book_file_id(&self) -> i64 { + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_files + .current_selection() + .id + } + + fn book_files_are_populated(&self) -> bool { + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .is_some_and(|modal| !modal.book_files.is_empty()) + } + + fn editions_are_populated(&self) -> bool { + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .is_some_and(|modal| !modal.editions.is_empty()) + } + + fn build_edition_details_modal(&mut self) { + let Edition { + title, + overview, + format, + publisher, + page_count, + release_date, + isbn13, + asin, + ratings, + language, + monitored, + .. + } = self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .editions + .current_selection() + .clone(); + let format = format.unwrap_or_default(); + let language = language.unwrap_or_default(); + let publisher = publisher.unwrap_or_default(); + let page_count = page_count + .map(|count| count.to_string()) + .unwrap_or_default(); + let release_date = release_date + .map(|date| date.to_string()) + .unwrap_or_default(); + let isbn13 = isbn13.unwrap_or_default(); + let asin = asin.unwrap_or_default(); + let ratings = ratings + .map(|Ratings { votes, value, .. }| format!("{value} ({votes} votes)")) + .unwrap_or_default(); + let overview = overview.unwrap_or_default(); + let details = formatdoc!( + " + Title: {title} + Format: {format} + Language: {language} + Publisher: {publisher} + Page Count: {page_count} + Release Date: {release_date} + ISBN13: {isbn13} + ASIN: {asin} + Ratings: {ratings} + Monitored: {monitored} + Overview: {overview} + " + ); + + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .edition_details_modal = Some(EditionDetailsModal { + edition_details: ScrollableText::with_string(details), + }); + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let editions_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::BookDetails.into()) + .searching_block(ActiveReadarrBlock::SearchEditions.into()) + .search_error_block(ActiveReadarrBlock::SearchEditionsError.into()) + .search_field_fn(|edition: &Edition| &edition.title); + + let book_history_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::BookHistory.into()) + .sorting_block(ActiveReadarrBlock::BookHistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchBookHistory.into()) + .search_error_block(ActiveReadarrBlock::SearchBookHistoryError.into()) + .search_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text) + .filtering_block(ActiveReadarrBlock::FilterBookHistory.into()) + .filter_error_block(ActiveReadarrBlock::FilterBookHistoryError.into()) + .filter_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text); + + let book_releases_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::ManualBookSearch.into()) + .sorting_block(ActiveReadarrBlock::ManualBookSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + + if !handle_table( + self, + |app| { + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .editions + }, + editions_table_handling_config, + ) && !handle_table( + self, + |app| { + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_history + }, + book_history_table_handling_config, + ) && !handle_table( + self, + |app| { + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_releases + }, + book_releases_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + BOOK_DETAILS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> BookDetailsHandler<'a, 'b> { + BookDetailsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + let Some(book_details_modal) = &self.app.data.readarr_data.book_details_modal else { + return false; + }; + + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails => !book_details_modal.editions.is_empty(), + ActiveReadarrBlock::BookHistory => !book_details_modal.book_history.is_empty(), + ActiveReadarrBlock::BookFileInfo => !book_details_modal.book_files.is_empty(), + ActiveReadarrBlock::ManualBookSearch => !book_details_modal.book_releases.is_empty(), + _ => true, + } + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::BookDetails + && self.book_files_are_populated() + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails + | ActiveReadarrBlock::BookHistory + | ActiveReadarrBlock::BookFileInfo + | ActiveReadarrBlock::ManualBookSearch => match self.key { + _ if matches_key!(left, self.key) => { + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_details_tabs + .previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_details_tabs + .get_active_route(), + ); + } + _ if matches_key!(right, self.key) => { + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_details_tabs + .next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_details_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveReadarrBlock::AutomaticallySearchBookPrompt + | ActiveReadarrBlock::ManualBookSearchConfirmPrompt + | ActiveReadarrBlock::DeleteBookFilePrompt => { + handle_prompt_toggle(self.app, self.key); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails if self.editions_are_populated() => { + self.build_edition_details_modal(); + self + .app + .push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + } + ActiveReadarrBlock::BookHistory => self + .app + .push_navigation_stack(ActiveReadarrBlock::BookHistoryDetails.into()), + ActiveReadarrBlock::ManualBookSearch => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + } + ActiveReadarrBlock::ManualBookSearchConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AutomaticallySearchBookPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticBookSearch(self.extract_book_id()), + ); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteBookFilePrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBookFile(self.extract_book_file_id())); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails + | ActiveReadarrBlock::BookFileInfo + | ActiveReadarrBlock::ManualBookSearch => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.book_details_modal = None; + } + ActiveReadarrBlock::BookHistoryDetails => { + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::BookHistory => { + if self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_history + .filtered_items + .is_some() + { + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_history + .reset_filter(); + } else { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.book_details_modal = None; + } + } + ActiveReadarrBlock::AutomaticallySearchBookPrompt + | ActiveReadarrBlock::ManualBookSearchConfirmPrompt + | ActiveReadarrBlock::DeleteBookFilePrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails + | ActiveReadarrBlock::BookHistory + | ActiveReadarrBlock::BookFileInfo + | ActiveReadarrBlock::ManualBookSearch => match key { + _ if matches_key!(refresh, key) => { + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + _ if matches_key!(auto_search, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + } + _ => (), + }, + ActiveReadarrBlock::AutomaticallySearchBookPrompt if matches_key!(confirm, key) => { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticBookSearch(self.extract_book_id()), + ); + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteBookFilePrompt if matches_key!(confirm, key) => { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBookFile(self.extract_book_file_id())); + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::ManualBookSearchConfirmPrompt if matches_key!(confirm, key) => { + self.app.data.readarr_data.prompt_confirm = true; + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs new file mode 100644 index 00000000..bd43b874 --- /dev/null +++ b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs @@ -0,0 +1,1084 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::book_details_handler::BookDetailsHandler; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ + Book, BookFile, Edition, Ratings, ReadarrHistoryItem, ReadarrRelease, + }; + use crate::models::servarr_data::readarr::modals::BookDetailsModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + mod test_handle_scroll_up_and_down { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + + #[rstest] + fn test_editions_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 2"); + } + + #[rstest] + fn test_editions_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 1"); + } + + #[rstest] + fn test_book_history_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .current_selection() + .source_title + .text, + "Test History 2" + ); + } + + #[rstest] + fn test_book_releases_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::ManualBookSearch, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .current_selection() + .title + .text, + "Test Release 2" + ); + } + } + + mod test_handle_home_end { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use pretty_assertions::assert_str_eq; + + #[test] + fn test_editions_home_end() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 2"); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 1"); + } + + #[test] + fn test_editions_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 1"); + } + } + + mod test_handle_delete { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_book_file_prompt() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteBookFilePrompt.into()); + } + + #[test] + fn test_delete_book_file_prompt_is_a_no_op_when_book_files_are_empty() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.book_files.set_items(Vec::new()); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + } + + mod test_handle_left_right_action { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use rstest::rstest; + + #[rstest] + #[case(ActiveReadarrBlock::BookDetails, ActiveReadarrBlock::BookHistory)] + #[case(ActiveReadarrBlock::BookHistory, ActiveReadarrBlock::ManualBookSearch)] + #[case(ActiveReadarrBlock::ManualBookSearch, ActiveReadarrBlock::BookDetails)] + fn test_book_details_tabs_left_right_action( + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + #[values(true, false)] is_loading: bool, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = is_loading; + app.push_navigation_stack(right_block.into()); + let tab_index = app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_details_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .book_details_tabs + .index = tab_index; + + BookDetailsHandler::new(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None).handle(); + + assert_navigation_pushed!(app, left_block.into()); + + BookDetailsHandler::new(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None).handle(); + + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + ActiveReadarrBlock::DeleteBookFilePrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + BookDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_pushed}; + use pretty_assertions::assert_eq; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_book_details_submit_builds_the_edition_details_modal_and_pushes_the_popup() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + assert_modal_present!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + assert!(edition_details_text(&app).contains("Title: Test Edition 1")); + assert!(edition_details_text(&app).contains("Format: Paperback")); + assert!(edition_details_text(&app).contains("ISBN13: isbn-1")); + assert!(edition_details_text(&app).contains("ASIN: asin-1")); + assert!(edition_details_text(&app).contains("Overview: Overview 1")); + } + + #[test] + fn test_book_details_submit_dispatches_no_network_event() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_book_details_submit_rebuilds_the_modal_when_a_different_edition_is_selected() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert!(edition_details_text(&app).contains("Title: Test Edition 1")); + assert!(edition_details_text(&app).contains("Format: Paperback")); + + app.pop_navigation_stack(); + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert!(edition_details_text(&app).contains("Title: Test Edition 2")); + assert!(edition_details_text(&app).contains("Format: Hardcover")); + assert!(edition_details_text(&app).contains("ISBN13: isbn-2")); + assert!(!edition_details_text(&app).contains("Test Edition 1")); + } + + #[test] + fn test_book_details_submit_is_a_no_op_when_the_editions_table_is_empty() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.editions.set_items(Vec::new()); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + } + + #[test] + fn test_book_details_submit_is_a_no_op_when_the_book_details_modal_is_absent() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_book_details_submit_with_a_single_edition() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.editions.set_items(vec![editions_vec()[0].clone()]); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + assert!(edition_details_text(&app).contains("Title: Test Edition 1")); + } + + #[test] + fn test_book_details_submit_renders_every_optional_field_when_absent() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.editions.set_items(vec![Edition { + title: "Bare Edition".to_owned(), + ..Edition::default() + }]); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + assert!(edition_details_text(&app).contains("Title: Bare Edition")); + assert!(edition_details_text(&app).contains("Format: \n")); + assert!(edition_details_text(&app).contains("Ratings: \n")); + } + + #[test] + fn test_book_details_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + } + + #[test] + fn test_book_history_submit() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookHistoryDetails.into()); + } + + #[test] + fn test_book_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookHistory.into()); + } + + #[test] + fn test_manual_book_search_submit() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualBookSearch, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into() + ); + } + + #[test] + fn test_manual_book_search_confirm_prompt_submit() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: "guid-1".to_owned(), + indexer_id: 1, + })) + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::ManualBookSearch.into()); + } + + #[test] + fn test_manual_book_search_confirm_prompt_submit_no_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + None, + ) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_pushed!(app, ActiveReadarrBlock::ManualBookSearch.into()); + } + + #[test] + fn test_automatically_search_book_prompt_submit() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::TriggerAutomaticBookSearch(7)) + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_automatically_search_book_prompt_submit_no_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + None, + ) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_submit() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookFilePrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBookFile(11)) + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_submit_no_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookFilePrompt, + None, + ) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + } + + mod test_handle_esc { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_book_details_tabs_esc_tears_down_the_book_details_modal( + #[values( + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_book_history_esc_tears_down_the_book_details_modal() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_book_history_esc_resets_the_filter_first() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.book_history.filtered_items = Some(history_vec()); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookHistory.into()); + assert_none!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .filtered_items + ); + } + + #[test] + fn test_book_history_details_esc() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistoryDetails.into()); + + BookDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::BookHistoryDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookHistory.into()); + assert_modal_present!(app.data.readarr_data.book_details_modal); + } + + #[rstest] + fn test_prompt_esc( + #[values( + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + ActiveReadarrBlock::DeleteBookFilePrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_key_char { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_refresh_key( + #[values( + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::ManualBookSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, active_readarr_block.into()); + } + + #[rstest] + fn test_auto_search_key( + #[values( + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::ManualBookSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt.into() + ); + } + + #[test] + fn test_auto_search_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_automatically_search_book_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::TriggerAutomaticBookSearch(7)) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::DeleteBookFilePrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBookFile(11)) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_manual_book_search_confirm_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: "guid-1".to_owned(), + indexer_id: 1, + })) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualBookSearch.into()); + } + } + + #[test] + fn test_book_details_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!( + BookDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the BookDetailsHandler" + ); + } else { + assert!( + !BookDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is wrongly accepted by the BookDetailsHandler" + ); + } + }); + } + + #[test] + fn test_book_details_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + + let handler = + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_book_details_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = App::test_default(); + + let handler = + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_book_details_handler_is_ready_per_tab() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + + for active_readarr_block in [ + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch, + ] { + let handler = BookDetailsHandler::new(DELETE_KEY, &mut app, active_readarr_block, None); + + assert!(handler.is_ready(), "{active_readarr_block} should be ready"); + } + } + + #[test] + fn test_book_details_handler_is_not_ready_when_its_tab_table_is_empty() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + + for active_readarr_block in [ + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch, + ] { + let handler = BookDetailsHandler::new(DELETE_KEY, &mut app, active_readarr_block, None); + + assert!( + !handler.is_ready(), + "{active_readarr_block} should not be ready" + ); + } + } + + #[test] + fn test_book_details_handler_ignores_special_keys_for_textbox_input() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None); + + assert!(handler.ignore_special_keys()); + } + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + fn selected_edition(app: &App<'_>) -> Edition { + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .editions + .current_selection() + .clone() + } + + fn edition_details_text(app: &App<'_>) -> String { + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + .as_ref() + .unwrap() + .edition_details + .get_text() + } + + fn book_details_modal() -> BookDetailsModal { + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal.editions.set_items(editions_vec()); + book_details_modal.book_files.set_items(book_files_vec()); + book_details_modal.book_history.set_items(history_vec()); + book_details_modal.book_releases.set_items(release_vec()); + + book_details_modal + } + + fn editions_vec() -> Vec { + vec![ + Edition { + id: 1, + book_id: 1, + title: "Test Edition 1".to_owned(), + format: Some("Paperback".to_owned()), + isbn13: Some("isbn-1".to_owned()), + asin: Some("asin-1".to_owned()), + publisher: Some("Publisher 1".to_owned()), + language: Some("eng".to_owned()), + overview: Some("Overview 1".to_owned()), + page_count: Some(662), + monitored: true, + ratings: Some(Ratings { + votes: 10, + value: 4.5, + popularity: None, + }), + ..Edition::default() + }, + Edition { + id: 2, + book_id: 1, + title: "Test Edition 2".to_owned(), + format: Some("Hardcover".to_owned()), + isbn13: Some("isbn-2".to_owned()), + asin: Some("asin-2".to_owned()), + publisher: Some("Publisher 2".to_owned()), + language: Some("eng".to_owned()), + overview: Some("Overview 2".to_owned()), + page_count: Some(722), + monitored: false, + ..Edition::default() + }, + ] + } + + fn book_files_vec() -> Vec { + vec![BookFile { + id: 11, + author_id: 1, + book_id: 1, + path: "/nfs/books/Test Author/Test Book.epub".to_owned(), + size: 1024, + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + ..BookFile::default() + }] + } + + fn books_vec() -> Vec { + vec![Book { + id: 7, + ..Book::default() + }] + } + + fn history_vec() -> Vec { + vec![ + ReadarrHistoryItem { + id: 1, + source_title: HorizontallyScrollableText::from("Test History 1"), + ..ReadarrHistoryItem::default() + }, + ReadarrHistoryItem { + id: 2, + source_title: HorizontallyScrollableText::from("Test History 2"), + ..ReadarrHistoryItem::default() + }, + ] + } + + fn release_vec() -> Vec { + vec![ + ReadarrRelease { + guid: "guid-1".to_owned(), + indexer_id: 1, + title: HorizontallyScrollableText::from("Test Release 1"), + ..ReadarrRelease::default() + }, + ReadarrRelease { + guid: "guid-2".to_owned(), + indexer_id: 2, + title: HorizontallyScrollableText::from("Test Release 2"), + ..ReadarrRelease::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index 3918ca47..f2af8d74 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -14,8 +14,9 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, LIBRARY_BLOCKS, + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; + use crate::test_handler_delegation; mod test_handle_scroll_up_and_down { use pretty_assertions::assert_str_eq; @@ -878,11 +879,21 @@ mod tests { assert_str_eq!(sort_option.name, "Tags"); } + #[test] + fn test_delegates_book_details_blocks_to_book_details_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::BookDetails + ); + } + #[test] fn test_library_handler_accepts() { let mut library_handler_blocks = Vec::new(); library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS); + library_handler_blocks.extend(BOOK_DETAILS_BLOCKS); ActiveReadarrBlock::iter().for_each(|readarr_block| { if library_handler_blocks.contains(&readarr_block) { diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index 69179113..9dd3fe36 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -20,8 +20,11 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::models::Route; mod author_details_handler; +mod book_details_handler; -pub(in crate::handlers::readarr_handlers) use author_details_handler::AuthorDetailsHandler; +pub(in crate::handlers::readarr_handlers) use { + author_details_handler::AuthorDetailsHandler, book_details_handler::BookDetailsHandler, +}; #[cfg(test)] #[path = "library_handler_tests.rs"] @@ -63,13 +66,19 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, AuthorDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); } + _ if BookDetailsHandler::accepts(self.active_readarr_block) => { + BookDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } _ => self.handle_key_event(), } } } fn accepts(active_block: ActiveReadarrBlock) -> bool { - AuthorDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) + AuthorDetailsHandler::accepts(active_block) + || BookDetailsHandler::accepts(active_block) + || LIBRARY_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { From 751ff6ecf5400c5aa44cb65b86c823b4e8a32cfb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 11:51:20 -0600 Subject: [PATCH 072/170] feat(readarr): scroll and dismiss the edition details popup Add EditionDetailsHandler, the single block popup opened from the editions table. It owns the arrow, Home and End scrolling over its ScrollableText body, and the Esc that pops the popup and clears the edition details modal. The handler is nested under BookDetailsHandler, which claims the block ahead of its own tables. It never sees the keypress that opens it, so it performs no build, and it dispatches no network events: the popup is assembled from an already loaded row. Scrolling is implemented rather than stubbed. The body is mostly the edition overview, which routinely overflows the popup, so empty scroll methods would leave the arrow keys dead on the longest text in the view. --- .../library/book_details_handler.rs | 13 +- .../library/book_details_handler_tests.rs | 18 +- .../library/edition_details_handler.rs | 125 ++++++ .../library/edition_details_handler_tests.rs | 420 ++++++++++++++++++ .../library/library_handler_tests.rs | 4 +- src/handlers/readarr_handlers/library/mod.rs | 1 + 6 files changed, 577 insertions(+), 4 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/edition_details_handler.rs create mode 100644 src/handlers/readarr_handlers/library/edition_details_handler_tests.rs diff --git a/src/handlers/readarr_handlers/library/book_details_handler.rs b/src/handlers/readarr_handlers/library/book_details_handler.rs index ee16c610..b49a60d9 100644 --- a/src/handlers/readarr_handlers/library/book_details_handler.rs +++ b/src/handlers/readarr_handlers/library/book_details_handler.rs @@ -13,6 +13,7 @@ use crate::network::readarr_network::ReadarrEvent; use indoc::formatdoc; use super::author_details_handler::releases_sorting_options; +use super::edition_details_handler::EditionDetailsHandler; #[cfg(test)] #[path = "book_details_handler_tests.rs"] @@ -133,6 +134,16 @@ impl BookDetailsHandler<'_, '_> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler<'a, 'b> { fn handle(&mut self) { + if EditionDetailsHandler::accepts(self.active_readarr_block) { + return EditionDetailsHandler::new( + self.key, + self.app, + self.active_readarr_block, + self._context, + ) + .handle(); + } + let editions_table_handling_config = TableHandlingConfig::new(ActiveReadarrBlock::BookDetails.into()) .searching_block(ActiveReadarrBlock::SearchEditions.into()) @@ -197,7 +208,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler< } fn accepts(active_block: ActiveReadarrBlock) -> bool { - BOOK_DETAILS_BLOCKS.contains(&active_block) + EditionDetailsHandler::accepts(active_block) || BOOK_DETAILS_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { diff --git a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs index bd43b874..886ebdef 100644 --- a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs @@ -13,9 +13,10 @@ mod tests { }; use crate::models::servarr_data::readarr::modals::BookDetailsModal; use crate::models::servarr_data::readarr::readarr_data::{ - ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, }; use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::test_handler_delegation; mod test_handle_scroll_up_and_down { use super::*; @@ -866,8 +867,12 @@ mod tests { #[test] fn test_book_details_handler_accepts() { + let mut book_details_handler_blocks = Vec::new(); + book_details_handler_blocks.extend(BOOK_DETAILS_BLOCKS); + book_details_handler_blocks.extend(EDITION_DETAILS_BLOCKS); + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { - if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) { + if book_details_handler_blocks.contains(&active_readarr_block) { assert!( BookDetailsHandler::accepts(active_readarr_block), "{active_readarr_block} is not accepted by the BookDetailsHandler" @@ -881,6 +886,15 @@ mod tests { }); } + #[test] + fn test_delegates_edition_details_blocks_to_edition_details_handler() { + test_handler_delegation!( + BookDetailsHandler, + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookEditionDetails + ); + } + #[test] fn test_book_details_handler_is_not_ready_when_loading() { let mut app = App::test_default(); diff --git a/src/handlers/readarr_handlers/library/edition_details_handler.rs b/src/handlers/readarr_handlers/library/edition_details_handler.rs new file mode 100644 index 00000000..9235b866 --- /dev/null +++ b/src/handlers/readarr_handlers/library/edition_details_handler.rs @@ -0,0 +1,125 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::matches_key; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDITION_DETAILS_BLOCKS, +}; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "edition_details_handler_tests.rs"] +mod edition_details_handler_tests; + +pub(super) struct EditionDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl EditionDetailsHandler<'_, '_> { + fn edition_details(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .edition_details_modal + .as_mut() + .expect("Edition details modal is undefined") + .edition_details + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for EditionDetailsHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + EDITION_DETAILS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> EditionDetailsHandler<'a, 'b> { + EditionDetailsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + let Some(book_details_modal) = &self.app.data.readarr_data.book_details_modal else { + return false; + }; + + book_details_modal.edition_details_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.edition_details().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.edition_details().scroll_down(); + } + + fn handle_home(&mut self) { + self.edition_details().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.edition_details().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) { + self.app.pop_navigation_stack(); + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .edition_details_modal = None; + } + + fn handle_char_key_event(&mut self) { + if matches_key!(refresh, self.key) { + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/edition_details_handler_tests.rs b/src/handlers/readarr_handlers/library/edition_details_handler_tests.rs new file mode 100644 index 00000000..f41215f3 --- /dev/null +++ b/src/handlers/readarr_handlers/library/edition_details_handler_tests.rs @@ -0,0 +1,420 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::edition_details_handler::EditionDetailsHandler; + use crate::models::ScrollableText; + use crate::models::servarr_data::readarr::modals::{BookDetailsModal, EditionDetailsModal}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDITION_DETAILS_BLOCKS, + }; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_edition_details_scroll_down() { + let mut app = app_with_edition_details(); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 1); + } + + #[test] + fn test_edition_details_scroll_up() { + let mut app = app_with_edition_details(); + set_offset(&mut app, 2); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 1); + } + + #[test] + fn test_edition_details_scroll_down_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 0); + } + + #[test] + fn test_edition_details_scroll_up_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + set_offset(&mut app, 2); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 2); + } + + #[test] + fn test_edition_details_scrolls_past_the_first_screen() { + let mut app = app_with_edition_details(); + + for _ in 0..4 { + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + } + + assert_eq!(edition_details_offset(&app), 4); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_edition_details_end() { + let mut app = app_with_edition_details(); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 5); + } + + #[test] + fn test_edition_details_home() { + let mut app = app_with_edition_details(); + set_offset(&mut app, 4); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 0); + } + + #[test] + fn test_edition_details_end_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 0); + } + + #[test] + fn test_edition_details_home_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + set_offset(&mut app, 4); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 4); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; + + #[test] + fn test_edition_details_esc_tears_down_the_edition_details_modal() { + let mut app = app_with_edition_details(); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + assert_modal_present!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_edition_details_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_edition_details(); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_refresh_key() { + let mut app = app_with_edition_details(); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + } + + #[test] + fn test_refresh_key_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookHistory.into()); + } + } + + #[test] + fn test_edition_details_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!( + EditionDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the EditionDetailsHandler" + ); + } else { + assert!( + !EditionDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is wrongly accepted by the EditionDetailsHandler" + ); + } + }); + } + + #[test] + fn test_edition_details_handler_dispatches_no_network_events() { + let mut app = app_with_edition_details(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + EditionDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookEditionDetails, None) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + #[test] + fn test_edition_details_handler_is_not_ready_when_loading() { + let mut app = app_with_edition_details(); + app.is_loading = true; + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edition_details_handler_is_not_ready_when_the_book_details_modal_is_absent() { + let mut app = App::test_default(); + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edition_details_handler_is_not_ready_when_the_edition_details_modal_is_absent() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edition_details_handler_is_ready() { + let mut app = app_with_edition_details(); + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_edition_details_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_edition_details(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_edition_details() -> App<'static> { + let mut app = App::test_default(); + let book_details_modal = BookDetailsModal { + edition_details_modal: Some(EditionDetailsModal { + edition_details: ScrollableText::with_string( + "Title: Test Edition\nFormat: Paperback\nLanguage: eng\nPublisher: Test\nPage Count: 662\nOverview: A long overview" + .to_owned(), + ), + }), + ..BookDetailsModal::default() + }; + app.data.readarr_data.book_details_modal = Some(book_details_modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + app + } + + fn edition_details_offset(app: &App<'_>) -> u16 { + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + .as_ref() + .unwrap() + .edition_details + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .edition_details_modal + .as_mut() + .unwrap() + .edition_details + .offset = offset; + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index f2af8d74..f474ecd6 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -14,7 +14,8 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, LIBRARY_BLOCKS, + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, + LIBRARY_BLOCKS, }; use crate::test_handler_delegation; @@ -894,6 +895,7 @@ mod tests { library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS); library_handler_blocks.extend(BOOK_DETAILS_BLOCKS); + library_handler_blocks.extend(EDITION_DETAILS_BLOCKS); ActiveReadarrBlock::iter().for_each(|readarr_block| { if library_handler_blocks.contains(&readarr_block) { diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index 9dd3fe36..d4093bae 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -21,6 +21,7 @@ use crate::models::Route; mod author_details_handler; mod book_details_handler; +mod edition_details_handler; pub(in crate::handlers::readarr_handlers) use { author_details_handler::AuthorDetailsHandler, book_details_handler::BookDetailsHandler, From d97c20d5ac70f7055989487d44918abf2aada2a5 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 12:00:40 -0600 Subject: [PATCH 073/170] feat(readarr): reach the book file info tab from the book details tabs The book details handler already accepted the book file info block, but the tab state declared only three tabs, so left and right could never reach it. Add the File tab between History and Manual Search, backed by a new context clue static covering refresh, delete book file, and esc. --- src/app/readarr/readarr_context_clues.rs | 9 ++++++ .../readarr/readarr_context_clues_tests.rs | 30 +++++++++++++++++-- .../library/book_details_handler_tests.rs | 3 +- src/models/servarr_data/readarr/modals.rs | 9 +++++- .../servarr_data/readarr/modals_tests.rs | 28 ++++++++++++----- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/app/readarr/readarr_context_clues.rs b/src/app/readarr/readarr_context_clues.rs index 44f3a2bf..aad10fec 100644 --- a/src/app/readarr/readarr_context_clues.rs +++ b/src/app/readarr/readarr_context_clues.rs @@ -124,6 +124,15 @@ pub static BOOK_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), ]; +pub static BOOK_FILE_CONTEXT_CLUES: [ContextClue; 3] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.delete, "delete book file"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + pub static MANUAL_BOOK_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [ ( DEFAULT_KEYBINDINGS.refresh, diff --git a/src/app/readarr/readarr_context_clues_tests.rs b/src/app/readarr/readarr_context_clues_tests.rs index b0ef4ec0..47cb11af 100644 --- a/src/app/readarr/readarr_context_clues_tests.rs +++ b/src/app/readarr/readarr_context_clues_tests.rs @@ -9,8 +9,9 @@ mod tests { use crate::app::readarr::readarr_context_clues::{ ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES, AUTHOR_DETAILS_CONTEXT_CLUES, AUTHOR_HISTORY_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES, BOOK_DETAILS_CONTEXT_CLUES, - BOOK_HISTORY_CONTEXT_CLUES, EDITION_DETAILS_CONTEXT_CLUES, MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, - MANUAL_BOOK_SEARCH_CONTEXT_CLUES, ReadarrContextClueProvider, + BOOK_FILE_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, EDITION_DETAILS_CONTEXT_CLUES, + MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, MANUAL_BOOK_SEARCH_CONTEXT_CLUES, + ReadarrContextClueProvider, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::readarr::modals::BookDetailsModal; @@ -316,6 +317,28 @@ mod tests { assert_none!(book_history_context_clues_iter.next()); } + #[test] + fn test_book_file_context_clues() { + let mut book_file_context_clues_iter = BOOK_FILE_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + book_file_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + book_file_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.delete, "delete book file") + ); + assert_some_eq_x!( + book_file_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(book_file_context_clues_iter.next()); + } + #[test] fn test_manual_book_search_context_clues() { let mut manual_book_search_context_clues_iter = MANUAL_BOOK_SEARCH_CONTEXT_CLUES.iter(); @@ -389,7 +412,8 @@ mod tests { #[rstest] #[case(0, ActiveReadarrBlock::BookDetails, &BOOK_DETAILS_CONTEXT_CLUES)] #[case(1, ActiveReadarrBlock::BookHistory, &BOOK_HISTORY_CONTEXT_CLUES)] - #[case(2, ActiveReadarrBlock::ManualBookSearch, &MANUAL_BOOK_SEARCH_CONTEXT_CLUES)] + #[case(2, ActiveReadarrBlock::BookFileInfo, &BOOK_FILE_CONTEXT_CLUES)] + #[case(3, ActiveReadarrBlock::ManualBookSearch, &MANUAL_BOOK_SEARCH_CONTEXT_CLUES)] fn test_readarr_context_clue_provider_book_details_tabs( #[case] index: usize, #[case] active_readarr_block: ActiveReadarrBlock, diff --git a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs index 886ebdef..e539fb12 100644 --- a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs @@ -208,7 +208,8 @@ mod tests { #[rstest] #[case(ActiveReadarrBlock::BookDetails, ActiveReadarrBlock::BookHistory)] - #[case(ActiveReadarrBlock::BookHistory, ActiveReadarrBlock::ManualBookSearch)] + #[case(ActiveReadarrBlock::BookHistory, ActiveReadarrBlock::BookFileInfo)] + #[case(ActiveReadarrBlock::BookFileInfo, ActiveReadarrBlock::ManualBookSearch)] #[case(ActiveReadarrBlock::ManualBookSearch, ActiveReadarrBlock::BookDetails)] fn test_book_details_tabs_left_right_action( #[case] left_block: ActiveReadarrBlock, diff --git a/src/models/servarr_data/readarr/modals.rs b/src/models/servarr_data/readarr/modals.rs index 30b0a817..b696796d 100644 --- a/src/models/servarr_data/readarr/modals.rs +++ b/src/models/servarr_data/readarr/modals.rs @@ -1,6 +1,7 @@ use super::readarr_data::{ActiveReadarrBlock, ReadarrData}; use crate::app::readarr::readarr_context_clues::{ - BOOK_DETAILS_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, MANUAL_BOOK_SEARCH_CONTEXT_CLUES, + BOOK_DETAILS_CONTEXT_CLUES, BOOK_FILE_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, + MANUAL_BOOK_SEARCH_CONTEXT_CLUES, }; use crate::models::readarr_models::{BookFile, Edition, ReadarrHistoryItem, ReadarrRelease}; use crate::models::servarr_data::modals::EditIndexerModal; @@ -253,6 +254,12 @@ impl Default for BookDetailsModal { contextual_help: Some(&BOOK_HISTORY_CONTEXT_CLUES), config: None, }, + TabRoute { + title: "File".to_string(), + route: ActiveReadarrBlock::BookFileInfo.into(), + contextual_help: Some(&BOOK_FILE_CONTEXT_CLUES), + config: None, + }, TabRoute { title: "Manual Search".to_string(), route: ActiveReadarrBlock::ManualBookSearch.into(), diff --git a/src/models/servarr_data/readarr/modals_tests.rs b/src/models/servarr_data/readarr/modals_tests.rs index 3d7f7c7a..c6695a15 100644 --- a/src/models/servarr_data/readarr/modals_tests.rs +++ b/src/models/servarr_data/readarr/modals_tests.rs @@ -1,7 +1,8 @@ #[cfg(test)] mod tests { use crate::app::readarr::readarr_context_clues::{ - BOOK_DETAILS_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, MANUAL_BOOK_SEARCH_CONTEXT_CLUES, + BOOK_DETAILS_CONTEXT_CLUES, BOOK_FILE_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, + MANUAL_BOOK_SEARCH_CONTEXT_CLUES, }; use crate::models::readarr_models::{Author, MonitorType, NewItemMonitorType}; use crate::models::servarr_data::modals::EditIndexerModal; @@ -268,7 +269,7 @@ mod tests { assert_is_empty!(book_details_modal.book_releases); assert_is_empty!(book_details_modal.book_history); - assert_eq!(book_details_modal.book_details_tabs.tabs.len(), 3); + assert_eq!(book_details_modal.book_details_tabs.tabs.len(), 4); assert_str_eq!( book_details_modal.book_details_tabs.tabs[0].title, @@ -298,19 +299,30 @@ mod tests { ); assert_none!(book_details_modal.book_details_tabs.tabs[1].config); - assert_str_eq!( - book_details_modal.book_details_tabs.tabs[2].title, - "Manual Search" - ); + assert_str_eq!(book_details_modal.book_details_tabs.tabs[2].title, "File"); assert_eq!( book_details_modal.book_details_tabs.tabs[2].route, - ActiveReadarrBlock::ManualBookSearch.into() + ActiveReadarrBlock::BookFileInfo.into() ); assert_some_eq_x!( &book_details_modal.book_details_tabs.tabs[2].contextual_help, - &MANUAL_BOOK_SEARCH_CONTEXT_CLUES + &BOOK_FILE_CONTEXT_CLUES ); assert_none!(book_details_modal.book_details_tabs.tabs[2].config); + + assert_str_eq!( + book_details_modal.book_details_tabs.tabs[3].title, + "Manual Search" + ); + assert_eq!( + book_details_modal.book_details_tabs.tabs[3].route, + ActiveReadarrBlock::ManualBookSearch.into() + ); + assert_some_eq_x!( + &book_details_modal.book_details_tabs.tabs[3].contextual_help, + &MANUAL_BOOK_SEARCH_CONTEXT_CLUES + ); + assert_none!(book_details_modal.book_details_tabs.tabs[3].config); } #[test] From b4c699436298adedb0c533ce049edbaf04f1368b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 12:12:03 -0600 Subject: [PATCH 074/170] feat(readarr): render the book details popup Draw the book details popup over the author details view, with the editions table, book history, the book file info tab, and the manual search releases table, plus the automatic search, delete book file, download release, and history details prompts. Edition row styling is extracted into a named function so each branch is unit tested; text snapshots discard colour and cannot see it. The shared test fixture now populates the edition format, ISBN13, ASIN, and publisher, and the book file media info, so the rendered columns are exercised rather than blank. --- .../servarr_data/readarr/readarr_data.rs | 14 +- .../readarr_ui/library/author_details_ui.rs | 7 +- .../library/author_details_ui_tests.rs | 19 +- src/ui/readarr_ui/library/book_details_ui.rs | 550 ++++++++++++++++++ .../library/book_details_ui_tests.rs | 242 ++++++++ src/ui/readarr_ui/library/library_ui_tests.rs | 3 +- src/ui/readarr_ui/library/mod.rs | 1 + ...ders_book_details_over_author_details.snap | 52 ++ ...nders_AutomaticallySearchBookPrompt_0.snap | 50 ++ ...nders_AutomaticallySearchBookPrompt_1.snap | 50 ++ ...nders_AutomaticallySearchBookPrompt_2.snap | 50 ++ ...nders_AutomaticallySearchBookPrompt_3.snap | 50 ++ ...s__book_details_renders_BookDetails_0.snap | 50 ++ ...__book_details_renders_BookFileInfo_2.snap | 50 ++ ..._details_renders_BookHistoryDetails_1.snap | 50 ++ ...tails_renders_BookHistorySortPrompt_1.snap | 50 ++ ...s__book_details_renders_BookHistory_1.snap | 50 ++ ...etails_renders_DeleteBookFilePrompt_0.snap | 50 ++ ...etails_renders_DeleteBookFilePrompt_2.snap | 50 ++ ...ails_renders_FilterBookHistoryError_1.snap | 50 ++ ...k_details_renders_FilterBookHistory_1.snap | 50 ++ ...nders_ManualBookSearchConfirmPrompt_3.snap | 50 ++ ..._renders_ManualBookSearchSortPrompt_3.snap | 50 ++ ...ok_details_renders_ManualBookSearch_3.snap | 50 ++ ...ails_renders_SearchBookHistoryError_1.snap | 50 ++ ...k_details_renders_SearchBookHistory_1.snap | 50 ++ ...details_renders_SearchEditionsError_0.snap | 50 ++ ...book_details_renders_SearchEditions_0.snap | 50 ++ ..._editions_table_with_a_single_edition.snap | 50 ++ ...ons_table_with_an_unmonitored_edition.snap | 50 ++ ...ers_editions_table_with_zero_editions.snap | 50 ++ ...n_the_book_details_modal_is_undefined.snap | 5 + ...sts__empty_book_details_BookDetails_0.snap | 50 ++ ...ts__empty_book_details_BookFileInfo_2.snap | 50 ++ ...sts__empty_book_details_BookHistory_1.snap | 50 ++ ...empty_book_details_ManualBookSearch_3.snap | 50 ++ ...s__loading_book_details_BookDetails_0.snap | 50 ++ ...__loading_book_details_BookFileInfo_2.snap | 50 ++ ...s__loading_book_details_BookHistory_1.snap | 50 ++ ...ading_book_details_ManualBookSearch_3.snap | 50 ++ 40 files changed, 2437 insertions(+), 6 deletions(-) create mode 100644 src/ui/readarr_ui/library/book_details_ui.rs create mode 100644 src/ui/readarr_ui/library/book_details_ui_tests.rs create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookDetails_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistoryDetails_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistorySortPrompt_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistory_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistoryError_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistory_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistoryError_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistory_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditionsError_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditions_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_a_single_edition.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_an_unmonitored_edition.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_zero_editions.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookDetails_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookFileInfo_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookHistory_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_ManualBookSearch_3.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookDetails_0.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookFileInfo_2.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookHistory_1.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_ManualBookSearch_3.snap diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index 08a98bd3..95d11212 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -29,7 +29,7 @@ use strum::EnumIter; use { super::modals::EditionDetailsModal, crate::models::readarr_models::{ - AuthorStatus, BookFile, DownloadStatus, Edition, MonitorType, NewItemMonitorType, + AuthorStatus, BookFile, DownloadStatus, Edition, MediaInfo, MonitorType, NewItemMonitorType, ReadarrHistoryEventType, ReadarrTaskName, }, crate::models::servarr_models::{IndexerField, Quality, QualityWrapper}, @@ -461,6 +461,10 @@ impl ReadarrData<'_> { monitored: true, is_ebook: true, title: "Test Edition".to_owned(), + format: Some("Hardcover".to_owned()), + publisher: Some("DAW Books".to_owned()), + isbn13: Some("9780756404079".to_owned()), + asin: Some("B0043RSJ9S".to_owned()), page_count: Some(288), release_date: Some(date), ..Edition::default() @@ -474,7 +478,13 @@ impl ReadarrData<'_> { size: 3543348019, date_added: date, quality: quality.clone(), - media_info: None, + media_info: Some(MediaInfo { + audio_bit_rate: Some("128 kbps".to_owned()), + audio_channels: 2, + audio_codec: Some("MP3".to_owned()), + audio_bits: Some("16".to_owned()), + audio_sample_rate: Some("44100".to_owned()), + }), quality_cutoff_not_met: false, }]); book_details_modal diff --git a/src/ui/readarr_ui/library/author_details_ui.rs b/src/ui/readarr_ui/library/author_details_ui.rs index abcbdf04..7817b7fb 100644 --- a/src/ui/readarr_ui/library/author_details_ui.rs +++ b/src/ui/readarr_ui/library/author_details_ui.rs @@ -13,6 +13,7 @@ use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; use crate::models::servarr_data::readarr::readarr_data::{ AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, }; +use crate::ui::readarr_ui::library::book_details_ui::BookDetailsUi; use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::decorate_peer_style; @@ -41,7 +42,7 @@ impl DrawUi for AuthorDetailsUi { let Route::Readarr(active_readarr_block, _) = route else { return false; }; - AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) + BookDetailsUi::accepts(route) || AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { @@ -125,6 +126,10 @@ impl DrawUi for AuthorDetailsUi { }; draw_popup(f, app, draw_author_details_popup, Size::XXLarge); + + if BookDetailsUi::accepts(route) { + BookDetailsUi::draw(f, app, _area); + } } } } diff --git a/src/ui/readarr_ui/library/author_details_ui_tests.rs b/src/ui/readarr_ui/library/author_details_ui_tests.rs index 7532a510..7abb83b7 100644 --- a/src/ui/readarr_ui/library/author_details_ui_tests.rs +++ b/src/ui/readarr_ui/library/author_details_ui_tests.rs @@ -7,7 +7,7 @@ mod tests { use crate::models::readarr_models::{Book, BookStatistics}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::author_details_ui::{ @@ -17,8 +17,11 @@ mod tests { #[test] fn test_author_details_ui_accepts() { + let mut author_details_blocks = AUTHOR_DETAILS_BLOCKS.to_vec(); + author_details_blocks.extend(BOOK_DETAILS_BLOCKS); + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { - if AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) { + if author_details_blocks.contains(&active_readarr_block) { assert!(AuthorDetailsUi::accepts(active_readarr_block.into())); } else { assert!(!AuthorDetailsUi::accepts(active_readarr_block.into())); @@ -295,5 +298,17 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_author_details_ui_renders_book_details_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/readarr_ui/library/book_details_ui.rs b/src/ui/readarr_ui/library/book_details_ui.rs new file mode 100644 index 00000000..335a65f1 --- /dev/null +++ b/src/ui/readarr_ui/library/book_details_ui.rs @@ -0,0 +1,550 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::{BookFile, Edition, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BOOK_DETAILS_BLOCKS}; +use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +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::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{DrawUi, draw_popup, draw_tabs}; +use crate::utils::convert_to_gb; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::prelude::{Line, Stylize, Text}; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; +use serde_json::Number; + +#[cfg(test)] +#[path = "book_details_ui_tests.rs"] +mod book_details_ui_tests; + +pub(super) struct BookDetailsUi; + +impl DrawUi for BookDetailsUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if app.data.readarr_data.book_details_modal.is_some() + && let Route::Readarr(active_readarr_block, _) = app.get_current_route() + { + let draw_book_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + let content_area = draw_tabs( + f, + popup_area, + &format!( + "{} Details", + app.data.readarr_data.books.current_selection().title.text + ), + &app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_details_tabs, + ); + draw_book_details(f, app, content_area); + + match active_readarr_block { + ActiveReadarrBlock::AutomaticallySearchBookPrompt => { + let prompt = format!( + "Do you want to trigger an automatic search of your indexers for the book: {}?", + app.data.readarr_data.books.current_selection().title.text + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Automatic Book Search") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::DeleteBookFilePrompt => { + let prompt = format!( + "Do you really want to delete this book file: \n{}?", + app.data.readarr_data.books.current_selection().title.text + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Book File") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::ManualBookSearchConfirmPrompt => { + draw_manual_book_search_confirm_prompt(f, app); + } + ActiveReadarrBlock::BookHistoryDetails => { + draw_history_item_details_popup(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_book_details_popup, Size::XLarge); + } + } +} + +fn draw_book_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Some(book_details_modal) = app.data.readarr_data.book_details_modal.as_ref() + && let Route::Readarr(active_readarr_block, _) = + book_details_modal.book_details_tabs.get_active_route() + { + match active_readarr_block { + ActiveReadarrBlock::BookDetails => draw_editions_table(f, app, area), + ActiveReadarrBlock::BookHistory => draw_book_history_table(f, app, area), + ActiveReadarrBlock::BookFileInfo => draw_book_file_info(f, app, area), + ActiveReadarrBlock::ManualBookSearch => draw_book_releases(f, app, area), + _ => (), + } + } +} + +fn draw_editions_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let content = Some( + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .editions, + ); + + let edition_row_mapping = |edition: &Edition| { + let Edition { + format, + isbn13, + asin, + publisher, + page_count, + release_date, + monitored, + .. + } = edition; + + let page_count = page_count + .map(|count| count.to_string()) + .unwrap_or_default(); + let release_date = release_date + .map(|date| date.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + let monitored = if *monitored { "🏷" } else { "" }; + + decorate_edition_row_with_style( + edition, + Row::new(vec![ + Cell::from(format.clone().unwrap_or_default()), + Cell::from(isbn13.clone().unwrap_or_default()), + Cell::from(asin.clone().unwrap_or_default()), + Cell::from(publisher.clone().unwrap_or_default()), + Cell::from(page_count), + Cell::from(release_date), + Cell::from(monitored.to_owned()), + ]), + ) + }; + + let is_searching = active_readarr_block == ActiveReadarrBlock::SearchEditions; + let editions_table = ManagarrTable::new(content, edition_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .searching(is_searching) + .search_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::SearchEditionsError, + ) + .headers([ + "Format", + "ISBN13", + "ASIN", + "Publisher", + "Page Count", + "Release Date", + "Monitored", + ]) + .constraints([ + Constraint::Percentage(14), + Constraint::Percentage(16), + Constraint::Percentage(14), + Constraint::Percentage(21), + Constraint::Percentage(11), + Constraint::Percentage(14), + Constraint::Percentage(10), + ]); + + if is_searching { + editions_table.show_cursor(f, area); + } + + f.render_widget(editions_table, area); + } +} + +fn decorate_edition_row_with_style<'a>(edition: &Edition, row: Row<'a>) -> Row<'a> { + if edition.monitored { + row.primary() + } else { + row.unmonitored() + } +} + +fn draw_book_file_info(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if !app.is_loading && !book_details_modal.book_files.is_empty() => { + let content = Some( + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_files, + ); + + let book_file_row_mapping = |book_file: &BookFile| { + let BookFile { + path, + size, + quality, + media_info, + .. + } = book_file; + + let size = convert_to_gb(*size); + let audio_info = media_info + .as_ref() + .map(|media_info| { + let codec = media_info.audio_codec.as_deref().unwrap_or(""); + let channels = format!("{}.0", media_info.audio_channels); + let bitrate = media_info.audio_bit_rate.as_deref().unwrap_or(""); + let sample_rate = media_info.audio_sample_rate.as_deref().unwrap_or(""); + let bits = media_info.audio_bits.as_deref().unwrap_or(""); + format!("{codec} - {channels} - {bitrate} - {sample_rate} - {bits}") + }) + .unwrap_or_default(); + + Row::new(vec![ + Cell::from(path.clone()), + Cell::from(format!("{size:.2} GB")), + Cell::from(quality.quality.name.clone()), + Cell::from(audio_info), + ]) + .primary() + }; + + let book_files_table = ManagarrTable::new(content, book_file_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .headers(["Path", "Size", "Quality", "Media Info"]) + .constraints([ + Constraint::Percentage(45), + Constraint::Percentage(11), + Constraint::Percentage(14), + Constraint::Percentage(30), + ]); + + f.render_widget(book_files_table, area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.book_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_book_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if !app.is_loading => { + let current_selection = if book_details_modal.book_history.is_empty() { + ReadarrHistoryItem::default() + } else { + book_details_modal.book_history.current_selection().clone() + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &ReadarrHistoryItem| { + let ReadarrHistoryItem { + source_title, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 40), + current_selection == *history_item, + app.should_text_scroll, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let mut book_history_table = &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_history; + let history_table = ManagarrTable::new(Some(&mut book_history_table), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::BookHistorySortPrompt) + .searching(active_readarr_block == ActiveReadarrBlock::SearchBookHistory) + .search_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::SearchBookHistoryError, + ) + .filtering(active_readarr_block == ActiveReadarrBlock::FilterBookHistory) + .filter_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::FilterBookHistoryError, + ) + .headers(["Source Title", "Event Type", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(15), + Constraint::Percentage(25), + ]); + + if [ + ActiveReadarrBlock::SearchBookHistory, + ActiveReadarrBlock::FilterBookHistory, + ] + .contains(&active_readarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.book_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_book_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if !app.is_loading => { + let (current_selection, is_empty) = if book_details_modal.book_releases.is_empty() { + (ReadarrRelease::default(), true) + } else { + ( + book_details_modal.book_releases.current_selection().clone(), + book_details_modal.book_releases.is_empty(), + ) + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let book_release_row_mapping = |release: &ReadarrRelease| { + let ReadarrRelease { + protocol, + age, + title, + indexer, + size, + rejected, + seeders, + leechers, + quality, + .. + } = release; + + let age = format!("{age} days"); + title.scroll_left_or_reset( + get_width_from_percentage(area, 35), + current_selection == *release + && active_readarr_block != ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + app.should_text_scroll, + ); + 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_or(Number::from(0u64)) + .as_u64() + .unwrap_or_default(); + let leechers = leechers + .clone() + .unwrap_or(Number::from(0u64)) + .as_u64() + .unwrap_or_default(); + + decorate_peer_style( + seeders, + leechers, + Text::from(format!("{seeders} / {leechers}")), + ) + }; + + let quality_name = 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(quality_name), + ]) + .primary() + }; + let mut book_release_table = &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_releases; + let release_table = + ManagarrTable::new(Some(&mut book_release_table), book_release_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading || is_empty) + .sorting(active_readarr_block == ActiveReadarrBlock::ManualBookSearchSortPrompt) + .headers([ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Quality", + ]) + .constraints([ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(5), + Constraint::Percentage(35), + Constraint::Percentage(15), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Percentage(10), + ]); + + f.render_widget(release_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.book_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_manual_book_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_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: {}?", + current_selection.title.text + ) + } else { + format!( + "Do you want to download the release: {}?", + current_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.readarr_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.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = + if let Some(book_details_modal) = app.data.readarr_data.book_details_modal.as_ref() { + if book_details_modal.book_history.is_empty() { + ReadarrHistoryItem::default() + } else { + book_details_modal.book_history.current_selection().clone() + } + } else { + ReadarrHistoryItem::default() + }; + + let line_vec = create_history_event_details(current_selection); + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowLongMessage), f.area()); +} diff --git a/src/ui/readarr_ui/library/book_details_ui_tests.rs b/src/ui/readarr_ui/library/book_details_ui_tests.rs new file mode 100644 index 00000000..1b5a21d3 --- /dev/null +++ b/src/ui/readarr_ui/library/book_details_ui_tests.rs @@ -0,0 +1,242 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::readarr_models::Edition; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::book_details_ui::{ + BookDetailsUi, decorate_edition_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_book_details_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!(BookDetailsUi::accepts(active_readarr_block.into())); + } else { + assert!(!BookDetailsUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_decorate_edition_row_with_style_monitored() { + let edition = Edition { + monitored: true, + ..Edition::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_edition_row_with_style(&edition, row.clone()); + + assert_eq!(style, row.primary()); + } + + #[test] + fn test_decorate_edition_row_with_style_unmonitored() { + let edition = Edition { + monitored: false, + ..Edition::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_edition_row_with_style(&edition, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + mod snapshot_tests { + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + use super::*; + + #[rstest] + #[case(ActiveReadarrBlock::BookDetails, 0)] + #[case(ActiveReadarrBlock::SearchEditions, 0)] + #[case(ActiveReadarrBlock::SearchEditionsError, 0)] + #[case(ActiveReadarrBlock::AutomaticallySearchBookPrompt, 0)] + #[case(ActiveReadarrBlock::AutomaticallySearchBookPrompt, 1)] + #[case(ActiveReadarrBlock::AutomaticallySearchBookPrompt, 2)] + #[case(ActiveReadarrBlock::AutomaticallySearchBookPrompt, 3)] + #[case(ActiveReadarrBlock::DeleteBookFilePrompt, 0)] + #[case(ActiveReadarrBlock::DeleteBookFilePrompt, 2)] + #[case(ActiveReadarrBlock::BookHistory, 1)] + #[case(ActiveReadarrBlock::SearchBookHistory, 1)] + #[case(ActiveReadarrBlock::SearchBookHistoryError, 1)] + #[case(ActiveReadarrBlock::FilterBookHistory, 1)] + #[case(ActiveReadarrBlock::FilterBookHistoryError, 1)] + #[case(ActiveReadarrBlock::BookHistorySortPrompt, 1)] + #[case(ActiveReadarrBlock::BookHistoryDetails, 1)] + #[case(ActiveReadarrBlock::BookFileInfo, 2)] + #[case(ActiveReadarrBlock::ManualBookSearch, 3)] + #[case(ActiveReadarrBlock::ManualBookSearchConfirmPrompt, 3)] + #[case(ActiveReadarrBlock::ManualBookSearchSortPrompt, 3)] + fn test_book_details_ui_renders( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .book_details_tabs + .set_index(index); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("book_details_renders_{active_readarr_block}_{index}"), + output + ); + } + + #[rstest] + #[case(ActiveReadarrBlock::BookDetails, 0)] + #[case(ActiveReadarrBlock::BookHistory, 1)] + #[case(ActiveReadarrBlock::BookFileInfo, 2)] + #[case(ActiveReadarrBlock::ManualBookSearch, 3)] + fn test_book_details_ui_renders_loading( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + { + let book_details_modal = app.data.readarr_data.book_details_modal.as_mut().unwrap(); + book_details_modal.editions = StatefulTable::default(); + book_details_modal.book_files = StatefulTable::default(); + book_details_modal.book_history = StatefulTable::default(); + book_details_modal.book_releases = StatefulTable::default(); + book_details_modal.book_details_tabs.set_index(index); + } + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("loading_book_details_{active_readarr_block}_{index}"), + output + ); + } + + #[rstest] + #[case(ActiveReadarrBlock::BookDetails, 0)] + #[case(ActiveReadarrBlock::BookHistory, 1)] + #[case(ActiveReadarrBlock::BookFileInfo, 2)] + #[case(ActiveReadarrBlock::ManualBookSearch, 3)] + fn test_book_details_ui_renders_empty( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + { + let book_details_modal = app.data.readarr_data.book_details_modal.as_mut().unwrap(); + book_details_modal.editions = StatefulTable::default(); + book_details_modal.book_files = StatefulTable::default(); + book_details_modal.book_history = StatefulTable::default(); + book_details_modal.book_releases = StatefulTable::default(); + book_details_modal.book_details_tabs.set_index(index); + } + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("empty_book_details_{active_readarr_block}_{index}"), + output + ); + } + + #[test] + fn test_book_details_ui_renders_editions_table_with_zero_editions() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .editions = StatefulTable::default(); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_book_details_ui_renders_editions_table_with_a_single_edition() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_book_details_ui_renders_editions_table_with_an_unmonitored_edition() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .editions + .set_items(vec![Edition { + monitored: false, + format: Some("Paperback".to_owned()), + isbn13: Some("9780756404741".to_owned()), + asin: Some("B0043RSJ9S".to_owned()), + publisher: Some("DAW Books".to_owned()), + page_count: Some(662), + ..Edition::default() + }]); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_book_details_ui_renders_nothing_when_the_book_details_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.data.readarr_data.book_details_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs index 2f51fddf..30585818 100644 --- a/src/ui/readarr_ui/library/library_ui_tests.rs +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, LIBRARY_BLOCKS, + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; @@ -16,6 +16,7 @@ mod tests { fn test_library_ui_accepts() { let mut blocks = LIBRARY_BLOCKS.to_vec(); blocks.extend(AUTHOR_DETAILS_BLOCKS); + blocks.extend(BOOK_DETAILS_BLOCKS); for active_readarr_block in ActiveReadarrBlock::iter() { if blocks.contains(&active_readarr_block) { diff --git a/src/ui/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index 49190511..62ec5f9e 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -26,6 +26,7 @@ use crate::{ }; mod author_details_ui; +mod book_details_ui; #[cfg(test)] #[path = "library_ui_tests.rs"] diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap new file mode 100644 index 00000000..7b962065 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Auth╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ + │Over│ Editions │ History │ File │ Manual Search │ │ + │Stat│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ + │Genr│ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ │ + │Rati│=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ │ + │Path│ │ │ + │Qual│ │ │ + │Meta│ │ │ + │Moni│ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │╭ A│ │─────╮│ + ││ Bo│ │ ││ + ││───│ │─────││ + ││ │ │ ││ + ││=> │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_0.snap new file mode 100644 index 00000000..97b8c5a4 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Automatic Book Search ─────────────────╮ │ + │ │Do you want to trigger an automatic search of your indexers│ │ + │ │ for the book: Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_1.snap new file mode 100644 index 00000000..99278512 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Automatic Book Search ─────────────────╮ │ + │ │Do you want to trigger an automatic search of your indexers│ │ + │ │ for the book: Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap new file mode 100644 index 00000000..3cfc96fa --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Path Size Quality Media Info │ + │=> /nfs/books/Test Author/Test Book.epub 3.30 GB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Automatic Book Search ─────────────────╮ │ + │ │Do you want to trigger an automatic search of your indexers│ │ + │ │ for the book: Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap new file mode 100644 index 00000000..a41752fe --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Automatic Book Search ─────────────────╮ │ + │ │Do you want to trigger an automatic search of your indexers│ │ + │ │ for the book: Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookDetails_0.snap new file mode 100644 index 00000000..f23a696c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookDetails_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap new file mode 100644 index 00000000..17c7120e --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Path Size Quality Media Info │ + │=> /nfs/books/Test Author/Test Book.epub 3.30 GB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistoryDetails_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistoryDetails_1.snap new file mode 100644 index 00000000..b725ed90 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistoryDetails_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ + │ │Source Title: Test Source Title │ │ + │ │Event Type: grabbed │ │ + │ │Quality: EPUB │ │ + │ │Date: 2023-05-20 21:29:16 UTC │ │ + │ │Indexer: │ │ + │ │NZB Info URL: │ │ + │ │Release Group: │ │ + │ │Age: 0 days │ │ + │ │Published Date: 1970-01-01 00:00:00 UTC │ │ + │ │Download Client: │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistorySortPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistorySortPrompt_1.snap new file mode 100644 index 00000000..38337ddb --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistorySortPrompt_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────╮ │ + │ │Something │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistory_1.snap new file mode 100644 index 00000000..30471ec0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_0.snap new file mode 100644 index 00000000..34e3d493 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────── Delete Book File ────────────────────╮ │ + │ │ Do you really want to delete this book file: │ │ + │ │ Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap new file mode 100644 index 00000000..c594e320 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Path Size Quality Media Info │ + │=> /nfs/books/Test Author/Test Book.epub 3.30 GB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────── Delete Book File ────────────────────╮ │ + │ │ Do you really want to delete this book file: │ │ + │ │ Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistoryError_1.snap new file mode 100644 index 00000000..7fd2c1f4 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistoryError_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────── Error ────────────╮ │ + │ │ The given filter produced empty│ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistory_1.snap new file mode 100644 index 00000000..d0a5de02 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────── Filter ──────────────╮ │ + │ │book history filter │ │ + │ ╰─────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap new file mode 100644 index 00000000..39997ba8 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Download Rejected Release ──────────────────╮ │ + │ │ Do you really want to download the rejected release: Test │ │ + │ │ Torrent Release? │ │ + │ │ │ │ + │ │ │ │ + │ │Rejection reasons: │ │ + │ │• Unknown quality profile │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭──────────────────────────────╮╭──────────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰──────────────────────────────╯╰──────────────────────────────╯│ │ + │ ╰────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap new file mode 100644 index 00000000..cf700c01 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Age ⛔ Title Indexer Size Peers Quality │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────╮ │ + │ │Something │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap new file mode 100644 index 00000000..d94fdc1b --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistoryError_1.snap new file mode 100644 index 00000000..c7da2a51 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistoryError_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────── Error ────────────╮ │ + │ │ No items found matching search │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistory_1.snap new file mode 100644 index 00000000..61d2bf2b --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────── Search ──────────────╮ │ + │ │book history search │ │ + │ ╰─────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditionsError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditionsError_0.snap new file mode 100644 index 00000000..da2f8e61 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditionsError_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────── Error ────────────╮ │ + │ │ No items found matching search │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditions_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditions_0.snap new file mode 100644 index 00000000..6d67ce02 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditions_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────── Search ──────────────╮ │ + │ │edition search │ │ + │ ╰─────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_a_single_edition.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_a_single_edition.snap new file mode 100644 index 00000000..f23a696c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_a_single_edition.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_an_unmonitored_edition.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_an_unmonitored_edition.snap new file mode 100644 index 00000000..74118def --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_an_unmonitored_edition.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Paperback 9780756404741 B0043RSJ9S DAW Books 662 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_zero_editions.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_zero_editions.snap new file mode 100644 index 00000000..f7d1165c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_zero_editions.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap new file mode 100644 index 00000000..9b84f8c0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookDetails_0.snap new file mode 100644 index 00000000..f7d1165c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookDetails_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookFileInfo_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookFileInfo_2.snap new file mode 100644 index 00000000..f7d1165c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookFileInfo_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookHistory_1.snap new file mode 100644 index 00000000..f7d1165c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_ManualBookSearch_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_ManualBookSearch_3.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_ManualBookSearch_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookDetails_0.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookDetails_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookFileInfo_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookFileInfo_2.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookFileInfo_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookHistory_1.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_ManualBookSearch_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_ManualBookSearch_3.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_ManualBookSearch_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ From 280e691a9430704658ea365674f1b6c8f27b077a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 12:18:56 -0600 Subject: [PATCH 075/170] feat(readarr): render the edition details popup Draw the edition details popup over the book details view. The popup body is the scrollable text the book details handler builds from the already loaded edition row, so it needs no tabs and no network call. The text styling is extracted into a named function so both branches are unit tested, and the shared test fixture now holds the real field lines instead of a placeholder, which exercises the scroll offset and the values that themselves contain a colon. --- .../servarr_data/readarr/readarr_data.rs | 15 +- .../library/author_details_ui_tests.rs | 3 +- src/ui/readarr_ui/library/book_details_ui.rs | 8 +- .../library/book_details_ui_tests.rs | 19 +- .../readarr_ui/library/edition_details_ui.rs | 102 ++++++++++ .../library/edition_details_ui_tests.rs | 176 ++++++++++++++++++ src/ui/readarr_ui/library/library_ui_tests.rs | 4 +- src/ui/readarr_ui/library/mod.rs | 1 + ...ers_edition_details_over_book_details.snap | 50 +++++ ...hot_tests__edition_details_ui_renders.snap | 48 +++++ ...sts__edition_details_ui_renders_empty.snap | 48 +++++ ...s__edition_details_ui_renders_loading.snap | 48 +++++ ...n_the_book_details_modal_is_undefined.snap | 5 + ...he_edition_details_modal_is_undefined.snap | 5 + ...__edition_details_ui_renders_scrolled.snap | 48 +++++ ...details_ui_renders_with_zero_editions.snap | 48 +++++ 16 files changed, 622 insertions(+), 6 deletions(-) create mode 100644 src/ui/readarr_ui/library/edition_details_ui.rs create mode 100644 src/ui/readarr_ui/library/edition_details_ui_tests.rs create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_empty.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_loading.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index 95d11212..3a1cb8ef 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -447,7 +447,20 @@ impl ReadarrData<'_> { .set_items(vec!["Standard".to_owned()]); let edition_details_modal = EditionDetailsModal { - edition_details: ScrollableText::with_string("Some details".to_owned()), + edition_details: ScrollableText::with_string( + "Title: Test Edition +Format: Hardcover +Language: English +Publisher: DAW Books +Page Count: 288 +Release Date: 2023-05-20 00:00:00 UTC +ISBN13: 9780756404079 +ASIN: B0043RSJ9S +Ratings: 4.5 (1000 votes) +Monitored: true +Overview: A test edition overview." + .to_owned(), + ), }; let mut book_details_modal = BookDetailsModal { diff --git a/src/ui/readarr_ui/library/author_details_ui_tests.rs b/src/ui/readarr_ui/library/author_details_ui_tests.rs index 7abb83b7..2358f45b 100644 --- a/src/ui/readarr_ui/library/author_details_ui_tests.rs +++ b/src/ui/readarr_ui/library/author_details_ui_tests.rs @@ -7,7 +7,7 @@ mod tests { use crate::models::readarr_models::{Book, BookStatistics}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::author_details_ui::{ @@ -19,6 +19,7 @@ mod tests { fn test_author_details_ui_accepts() { let mut author_details_blocks = AUTHOR_DETAILS_BLOCKS.to_vec(); author_details_blocks.extend(BOOK_DETAILS_BLOCKS); + author_details_blocks.extend(EDITION_DETAILS_BLOCKS); ActiveReadarrBlock::iter().for_each(|active_readarr_block| { if author_details_blocks.contains(&active_readarr_block) { diff --git a/src/ui/readarr_ui/library/book_details_ui.rs b/src/ui/readarr_ui/library/book_details_ui.rs index 335a65f1..a2b3b714 100644 --- a/src/ui/readarr_ui/library/book_details_ui.rs +++ b/src/ui/readarr_ui/library/book_details_ui.rs @@ -2,6 +2,7 @@ use crate::app::App; use crate::models::Route; use crate::models::readarr_models::{BookFile, Edition, ReadarrHistoryItem, ReadarrRelease}; use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BOOK_DETAILS_BLOCKS}; +use crate::ui::readarr_ui::library::edition_details_ui::EditionDetailsUi; use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::{ @@ -31,10 +32,11 @@ impl DrawUi for BookDetailsUi { let Route::Readarr(active_readarr_block, _) = route else { return false; }; - BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) + EditionDetailsUi::accepts(route) || BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + let route = app.get_current_route(); if app.data.readarr_data.book_details_modal.is_some() && let Route::Readarr(active_readarr_block, _) = app.get_current_route() { @@ -98,6 +100,10 @@ impl DrawUi for BookDetailsUi { }; draw_popup(f, app, draw_book_details_popup, Size::XLarge); + + if EditionDetailsUi::accepts(route) { + EditionDetailsUi::draw(f, app, _area); + } } } } diff --git a/src/ui/readarr_ui/library/book_details_ui_tests.rs b/src/ui/readarr_ui/library/book_details_ui_tests.rs index 1b5a21d3..8ed9a024 100644 --- a/src/ui/readarr_ui/library/book_details_ui_tests.rs +++ b/src/ui/readarr_ui/library/book_details_ui_tests.rs @@ -7,7 +7,7 @@ mod tests { use crate::app::App; use crate::models::readarr_models::Edition; use crate::models::servarr_data::readarr::readarr_data::{ - ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::book_details_ui::{ @@ -18,8 +18,11 @@ mod tests { #[test] fn test_book_details_ui_accepts() { + let mut book_details_blocks = BOOK_DETAILS_BLOCKS.to_vec(); + book_details_blocks.extend(EDITION_DETAILS_BLOCKS); + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { - if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) { + if book_details_blocks.contains(&active_readarr_block) { assert!(BookDetailsUi::accepts(active_readarr_block.into())); } else { assert!(!BookDetailsUi::accepts(active_readarr_block.into())); @@ -238,5 +241,17 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_book_details_ui_renders_edition_details_over_book_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/readarr_ui/library/edition_details_ui.rs b/src/ui/readarr_ui/library/edition_details_ui.rs new file mode 100644 index 00000000..811bd1e6 --- /dev/null +++ b/src/ui/readarr_ui/library/edition_details_ui.rs @@ -0,0 +1,102 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::Edition; +use crate::models::servarr_data::readarr::readarr_data::EDITION_DETAILS_BLOCKS; +use crate::ui::styles::{primary_style, unmonitored_style}; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +#[cfg(test)] +#[path = "edition_details_ui_tests.rs"] +mod edition_details_ui_tests; + +pub(super) struct EditionDetailsUi; + +impl DrawUi for EditionDetailsUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Some(book_details_modal) = app.data.readarr_data.book_details_modal.as_ref() + && book_details_modal.edition_details_modal.is_some() + { + let draw_edition_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + draw_edition_details(f, app, popup_area); + }; + + draw_popup(f, app, draw_edition_details_popup, Size::Large); + } + } +} + +fn draw_edition_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block_centered("Edition Details"); + + match app.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if !app.is_loading => { + if let Some(edition_details_modal) = book_details_modal.edition_details_modal.as_ref() { + let style = if book_details_modal.editions.is_empty() { + primary_style() + } else { + style_from_edition(book_details_modal.editions.current_selection()) + }; + let edition_details = &edition_details_modal.edition_details; + let text = Text::from( + edition_details + .items + .iter() + .filter(|line| !line.is_empty()) + .map(|line| { + let split = line.split(':').collect::>(); + let title = format!("{}:", split[0]); + + 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((edition_details.offset, 0)); + + f.render_widget(paragraph, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading + || app + .data + .readarr_data + .book_details_modal + .as_ref() + .is_none_or(|book_details_modal| book_details_modal.edition_details_modal.is_none()), + block, + ), + area, + ), + } +} + +fn style_from_edition(edition: &Edition) -> Style { + if !edition.monitored { + return unmonitored_style(); + } + + primary_style() +} diff --git a/src/ui/readarr_ui/library/edition_details_ui_tests.rs b/src/ui/readarr_ui/library/edition_details_ui_tests.rs new file mode 100644 index 00000000..7d7c6add --- /dev/null +++ b/src/ui/readarr_ui/library/edition_details_ui_tests.rs @@ -0,0 +1,176 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::readarr_models::Edition; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDITION_DETAILS_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::edition_details_ui::{EditionDetailsUi, style_from_edition}; + use crate::ui::styles::{primary_style, unmonitored_style}; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_edition_details_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!(EditionDetailsUi::accepts(active_readarr_block.into())); + } else { + assert!(!EditionDetailsUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_style_from_edition_monitored() { + let edition = Edition { + monitored: true, + ..Edition::default() + }; + + let style = style_from_edition(&edition); + + assert_eq!(style, primary_style()); + } + + #[test] + fn test_style_from_edition_unmonitored() { + let edition = Edition { + monitored: false, + ..Edition::default() + }; + + let style = style_from_edition(&edition); + + assert_eq!(style, unmonitored_style()); + } + + mod snapshot_tests { + use crate::models::servarr_data::readarr::modals::EditionDetailsModal; + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_edition_details_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_empty() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .edition_details_modal = Some(EditionDetailsModal::default()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_with_zero_editions() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .editions = StatefulTable::default(); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_scrolled() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .edition_details_modal + .as_mut() + .unwrap() + .edition_details + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .edition_details_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app.data.readarr_data.book_details_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs index 30585818..757ffde3 100644 --- a/src/ui/readarr_ui/library/library_ui_tests.rs +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -4,7 +4,8 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, LIBRARY_BLOCKS, + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, + LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; @@ -17,6 +18,7 @@ mod tests { let mut blocks = LIBRARY_BLOCKS.to_vec(); blocks.extend(AUTHOR_DETAILS_BLOCKS); blocks.extend(BOOK_DETAILS_BLOCKS); + blocks.extend(EDITION_DETAILS_BLOCKS); for active_readarr_block in ActiveReadarrBlock::iter() { if blocks.contains(&active_readarr_block) { diff --git a/src/ui/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index 62ec5f9e..865576af 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -27,6 +27,7 @@ use crate::{ mod author_details_ui; mod book_details_ui; +mod edition_details_ui; #[cfg(test)] #[path = "library_ui_tests.rs"] diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap new file mode 100644 index 00000000..2c0295dd --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │──────╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮─────│ + │ For│Title: Test Edition │d │ + │=> Har│Format: Hardcover │ │ + │ │Language: English │ │ + │ │Publisher: DAW Books │ │ + │ │Page Count: 288 │ │ + │ │Release Date: 2023-05-20 00:00:00 UTC │ │ + │ │ISBN13: 9780756404079 │ │ + │ │ASIN: B0043RSJ9S │ │ + │ │Ratings: 4.5 (1000 votes) │ │ + │ │Monitored: true │ │ + │ │Overview: A test edition overview. │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap new file mode 100644 index 00000000..1e44dfb2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │Title: Test Edition │ + │Format: Hardcover │ + │Language: English │ + │Publisher: DAW Books │ + │Page Count: 288 │ + │Release Date: 2023-05-20 00:00:00 UTC │ + │ISBN13: 9780756404079 │ + │ASIN: B0043RSJ9S │ + │Ratings: 4.5 (1000 votes) │ + │Monitored: true │ + │Overview: A test edition overview. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_empty.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_empty.snap new file mode 100644 index 00000000..2c3e7f2d --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_empty.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_loading.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_loading.snap new file mode 100644 index 00000000..6f99c2ff --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap new file mode 100644 index 00000000..8e88fe26 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined.snap new file mode 100644 index 00000000..8e88fe26 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap new file mode 100644 index 00000000..94901d82 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │Language: English │ + │Publisher: DAW Books │ + │Page Count: 288 │ + │Release Date: 2023-05-20 00:00:00 UTC │ + │ISBN13: 9780756404079 │ + │ASIN: B0043RSJ9S │ + │Ratings: 4.5 (1000 votes) │ + │Monitored: true │ + │Overview: A test edition overview. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap new file mode 100644 index 00000000..1e44dfb2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │Title: Test Edition │ + │Format: Hardcover │ + │Language: English │ + │Publisher: DAW Books │ + │Page Count: 288 │ + │Release Date: 2023-05-20 00:00:00 UTC │ + │ISBN13: 9780756404079 │ + │ASIN: B0043RSJ9S │ + │Ratings: 4.5 (1000 votes) │ + │Monitored: true │ + │Overview: A test edition overview. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ From 61a18cb2221b827a114b2d5905e09642433cddb5 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 13:11:51 -0600 Subject: [PATCH 076/170] fix(readarr): move the book file delete to the File tab Pressing Del on the Editions tab opened a delete prompt operating on the currently selected book file, which is not visible from that tab. On the File tab, where the book files are listed, Del did nothing even though the tab advertised a "delete book file" hint. Gate the delete on the File tab instead and drop the now inaccurate hint from the Editions tab clue set. A Readarr edition carries no file id, unlike a Lidarr track, so the book files live in their own table and the delete cannot be driven from the editions table. --- src/app/readarr/readarr_context_clues.rs | 3 +- .../readarr/readarr_context_clues_tests.rs | 4 --- .../library/book_details_handler.rs | 2 +- .../library/book_details_handler_tests.rs | 28 ++++++++++++++----- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/app/readarr/readarr_context_clues.rs b/src/app/readarr/readarr_context_clues.rs index aad10fec..442fabbb 100644 --- a/src/app/readarr/readarr_context_clues.rs +++ b/src/app/readarr/readarr_context_clues.rs @@ -93,7 +93,7 @@ pub static MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES: [ContextClue; 7] = [ (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; -pub static BOOK_DETAILS_CONTEXT_CLUES: [ContextClue; 6] = [ +pub static BOOK_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, @@ -105,7 +105,6 @@ pub static BOOK_DETAILS_CONTEXT_CLUES: [ContextClue; 6] = [ ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.submit, "edition details"), - (DEFAULT_KEYBINDINGS.delete, "delete book file"), ]; pub static BOOK_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [ diff --git a/src/app/readarr/readarr_context_clues_tests.rs b/src/app/readarr/readarr_context_clues_tests.rs index 47cb11af..76eb259b 100644 --- a/src/app/readarr/readarr_context_clues_tests.rs +++ b/src/app/readarr/readarr_context_clues_tests.rs @@ -269,10 +269,6 @@ mod tests { book_details_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.submit, "edition details") ); - assert_some_eq_x!( - book_details_context_clues_iter.next(), - &(DEFAULT_KEYBINDINGS.delete, "delete book file") - ); assert_none!(book_details_context_clues_iter.next()); } diff --git a/src/handlers/readarr_handlers/library/book_details_handler.rs b/src/handlers/readarr_handlers/library/book_details_handler.rs index b49a60d9..092b46b5 100644 --- a/src/handlers/readarr_handlers/library/book_details_handler.rs +++ b/src/handlers/readarr_handlers/library/book_details_handler.rs @@ -260,7 +260,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler< fn handle_end(&mut self) {} fn handle_delete(&mut self) { - if self.active_readarr_block == ActiveReadarrBlock::BookDetails + if self.active_readarr_block == ActiveReadarrBlock::BookFileInfo && self.book_files_are_populated() { self diff --git a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs index e539fb12..e092410c 100644 --- a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs @@ -164,13 +164,25 @@ mod tests { #[test] fn test_delete_book_file_prompt() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookFileInfo.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookFileInfo, None) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteBookFilePrompt.into()); + } + + #[test] + fn test_delete_book_file_prompt_is_a_no_op_on_the_editions_tab() { let mut app = App::test_default(); app.data.readarr_data.book_details_modal = Some(book_details_modal()); app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); - assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteBookFilePrompt.into()); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); } #[test] @@ -179,11 +191,12 @@ mod tests { let mut modal = book_details_modal(); modal.book_files.set_items(Vec::new()); app.data.readarr_data.book_details_modal = Some(modal); - app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookFileInfo.into()); - BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookFileInfo, None) + .handle(); - assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookFileInfo.into()); } #[test] @@ -191,11 +204,12 @@ mod tests { let mut app = App::test_default(); app.is_loading = true; app.data.readarr_data.book_details_modal = Some(book_details_modal()); - app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookFileInfo.into()); - BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookFileInfo, None) + .handle(); - assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookFileInfo.into()); } } From a311e2b89563e6fd6b4fde9856b0ad6efed78748 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 13:20:21 -0600 Subject: [PATCH 077/170] fix(readarr): stop appending stray colons to the edition overview Every line of the edition details popup was parsed as a label and a value, so a line carrying no colon had one appended to it. A multi-line overview therefore rendered with a spurious colon at the end of each continuation line, and Goodreads sourced descriptions, which use CRLF, turned their blank lines into a lone colon. Split each line on the first colon only, leave a line without one unlabelled, and trim the trailing carriage return so a CRLF blank line renders as a real paragraph break. Building the line is now a named function so its branches can be asserted directly. Rendered text discards the carriage return, so a snapshot alone cannot tell the trimming apart from leaving it in place. A continuation line that happens to contain a colon is still styled as a label. Telling those apart needs the details to be built as styled lines rather than flattened to text and parsed back. --- .../servarr_data/readarr/readarr_data.rs | 26 ++++---- .../readarr_ui/library/edition_details_ui.rs | 25 ++++---- .../library/edition_details_ui_tests.rs | 63 ++++++++++++++++++- ...ers_edition_details_over_book_details.snap | 6 +- ...hot_tests__edition_details_ui_renders.snap | 6 +- ...__edition_details_ui_renders_scrolled.snap | 6 +- ...details_ui_renders_with_zero_editions.snap | 6 +- 7 files changed, 103 insertions(+), 35 deletions(-) diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index 3a1cb8ef..30ad8344 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -448,17 +448,21 @@ impl ReadarrData<'_> { let edition_details_modal = EditionDetailsModal { edition_details: ScrollableText::with_string( - "Title: Test Edition -Format: Hardcover -Language: English -Publisher: DAW Books -Page Count: 288 -Release Date: 2023-05-20 00:00:00 UTC -ISBN13: 9780756404079 -ASIN: B0043RSJ9S -Ratings: 4.5 (1000 votes) -Monitored: true -Overview: A test edition overview." + "Title: Test Edition\n\ + Format: Hardcover\n\ + Language: English\n\ + Publisher: DAW Books\n\ + Page Count: 288\n\ + Release Date: 2023-05-20 00:00:00 UTC\n\ + ISBN13: 9780756404079\n\ + ASIN: B0043RSJ9S\n\ + Ratings: 4.5 (1000 votes)\n\ + Monitored: true\n\ + Overview: original cover of ISBN 075640407X\r\n\ + \r\n\ + My name is Kvothe, pronounced nearly the same as \"quothe.\"\r\n\ + \r\n\ + The Adem call me Maedre: which means The Flame.\r\n" .to_owned(), ), }; diff --git a/src/ui/readarr_ui/library/edition_details_ui.rs b/src/ui/readarr_ui/library/edition_details_ui.rs index 811bd1e6..8f4d5c0c 100644 --- a/src/ui/readarr_ui/library/edition_details_ui.rs +++ b/src/ui/readarr_ui/library/edition_details_ui.rs @@ -56,17 +56,8 @@ fn draw_edition_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { edition_details .items .iter() - .filter(|line| !line.is_empty()) - .map(|line| { - let split = line.split(':').collect::>(); - let title = format!("{}:", split[0]); - - Line::from(vec![ - title.bold().style(style), - Span::styled(split[1..].join(":"), style), - ]) - }) - .collect::>>(), + .map(|line| edition_detail_line(line, style)) + .collect::>>(), ); let paragraph = Paragraph::new(text) @@ -100,3 +91,15 @@ fn style_from_edition(edition: &Edition) -> Style { primary_style() } + +fn edition_detail_line(line: &str, style: Style) -> Line<'static> { + let line = line.trim_end(); + + match line.split_once(':') { + Some((label, value)) => Line::from(vec![ + format!("{label}:").bold().style(style), + Span::styled(value.to_owned(), style), + ]), + None => Line::from(Span::styled(line.to_owned(), style)), + } +} diff --git a/src/ui/readarr_ui/library/edition_details_ui_tests.rs b/src/ui/readarr_ui/library/edition_details_ui_tests.rs index 7d7c6add..8d9a94d5 100644 --- a/src/ui/readarr_ui/library/edition_details_ui_tests.rs +++ b/src/ui/readarr_ui/library/edition_details_ui_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { use pretty_assertions::assert_eq; + use ratatui::text::Line; use strum::IntoEnumIterator; use crate::app::App; @@ -9,7 +10,9 @@ mod tests { ActiveReadarrBlock, EDITION_DETAILS_BLOCKS, }; use crate::ui::DrawUi; - use crate::ui::readarr_ui::library::edition_details_ui::{EditionDetailsUi, style_from_edition}; + use crate::ui::readarr_ui::library::edition_details_ui::{ + EditionDetailsUi, edition_detail_line, style_from_edition, + }; use crate::ui::styles::{primary_style, unmonitored_style}; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; @@ -48,6 +51,64 @@ mod tests { assert_eq!(style, unmonitored_style()); } + #[test] + fn test_edition_detail_line_splits_a_label_from_its_value() { + let line = edition_detail_line("Title: Test Edition", primary_style()); + + assert_eq!(span_contents(&line), vec!["Title:", " Test Edition"]); + } + + #[test] + fn test_edition_detail_line_keeps_colons_within_a_value() { + let line = edition_detail_line("Release Date: 2023-01-05 15:00:00 UTC", primary_style()); + + assert_eq!( + span_contents(&line), + vec!["Release Date:", " 2023-01-05 15:00:00 UTC"] + ); + } + + #[test] + fn test_edition_detail_line_does_not_append_a_colon_to_a_line_without_one() { + let line = edition_detail_line( + "My name is Kvothe, pronounced nearly the same as \"quothe.\"", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["My name is Kvothe, pronounced nearly the same as \"quothe.\""] + ); + } + + #[test] + fn test_edition_detail_line_renders_a_carriage_return_only_line_as_blank() { + let line = edition_detail_line("\r", primary_style()); + + assert_eq!(span_contents(&line), vec![""]); + } + + #[test] + fn test_edition_detail_line_trims_a_trailing_carriage_return() { + let line = edition_detail_line( + "I have stolen princesses back from sleeping barrow kings.\r", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["I have stolen princesses back from sleeping barrow kings."] + ); + } + + fn span_contents(line: &Line<'_>) -> Vec { + line + .spans + .iter() + .map(|span| span.content.to_string()) + .collect() + } + mod snapshot_tests { use crate::models::servarr_data::readarr::modals::EditionDetailsModal; use crate::models::stateful_table::StatefulTable; diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap index 2c0295dd..b675a77a 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap @@ -19,11 +19,11 @@ expression: output │ │ASIN: B0043RSJ9S │ │ │ │Ratings: 4.5 (1000 votes) │ │ │ │Monitored: true │ │ - │ │Overview: A test edition overview. │ │ - │ │ │ │ - │ │ │ │ + │ │Overview: original cover of ISBN 075640407X │ │ │ │ │ │ + │ │My name is Kvothe, pronounced nearly the same as "quothe." │ │ │ │ │ │ + │ │The Adem call me Maedre: which means The Flame. │ │ │ │ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap index 1e44dfb2..a8f37c38 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap @@ -19,11 +19,11 @@ expression: output │ASIN: B0043RSJ9S │ │Ratings: 4.5 (1000 votes) │ │Monitored: true │ - │Overview: A test edition overview. │ - │ │ - │ │ + │Overview: original cover of ISBN 075640407X │ │ │ + │My name is Kvothe, pronounced nearly the same as "quothe." │ │ │ + │The Adem call me Maedre: which means The Flame. │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap index 94901d82..38e4b079 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap @@ -17,11 +17,11 @@ expression: output │ASIN: B0043RSJ9S │ │Ratings: 4.5 (1000 votes) │ │Monitored: true │ - │Overview: A test edition overview. │ - │ │ - │ │ + │Overview: original cover of ISBN 075640407X │ │ │ + │My name is Kvothe, pronounced nearly the same as "quothe." │ │ │ + │The Adem call me Maedre: which means The Flame. │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap index 1e44dfb2..a8f37c38 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap @@ -19,11 +19,11 @@ expression: output │ASIN: B0043RSJ9S │ │Ratings: 4.5 (1000 votes) │ │Monitored: true │ - │Overview: A test edition overview. │ - │ │ - │ │ + │Overview: original cover of ISBN 075640407X │ │ │ + │My name is Kvothe, pronounced nearly the same as "quothe." │ │ │ + │The Adem call me Maedre: which means The Flame. │ │ │ │ │ │ │ From da6c9e7b66952cb231ff2961f293f481a8417568 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 16:31:12 -0600 Subject: [PATCH 078/170] fix(ui): render detail popup labels in bold Span::style assigns rather than patches, so chaining it after bold() discarded the modifier that bold() had just set and every detail popup label rendered plain. Apply the modifier to the style itself instead. Snapshots compare rendered text and cannot observe styling, so this is pinned by asserting the label span's modifier directly. --- src/ui/lidarr_ui/library/track_details_ui.rs | 4 ++-- src/ui/radarr_ui/library/movie_details_ui.rs | 2 +- src/ui/readarr_ui/library/edition_details_ui.rs | 4 ++-- src/ui/readarr_ui/library/edition_details_ui_tests.rs | 10 ++++++++++ src/ui/sonarr_ui/library/episode_details_ui.rs | 2 +- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/ui/lidarr_ui/library/track_details_ui.rs b/src/ui/lidarr_ui/library/track_details_ui.rs index f9c3d72a..9dac1205 100644 --- a/src/ui/lidarr_ui/library/track_details_ui.rs +++ b/src/ui/lidarr_ui/library/track_details_ui.rs @@ -13,7 +13,7 @@ use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Style; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; @@ -97,7 +97,7 @@ fn draw_track_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let style = style_from_status(&track); Line::from(vec![ - title.bold().style(style), + Span::styled(title, style.bold()), Span::styled(split[1..].join(":"), style), ]) }) diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index bd2c41f2..c562cbfe 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -204,7 +204,7 @@ fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let style = style_from_download_status(download_status, is_monitored, status.clone()); Line::from(vec![ - title.bold().style(style), + Span::styled(title, style.bold()), Span::styled(split[1..].join(":"), style), ]) }) diff --git a/src/ui/readarr_ui/library/edition_details_ui.rs b/src/ui/readarr_ui/library/edition_details_ui.rs index 8f4d5c0c..e2bdbb33 100644 --- a/src/ui/readarr_ui/library/edition_details_ui.rs +++ b/src/ui/readarr_ui/library/edition_details_ui.rs @@ -9,7 +9,7 @@ use crate::ui::widgets::popup::Size; use crate::ui::{DrawUi, draw_popup}; use ratatui::Frame; use ratatui::layout::Rect; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Style; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Paragraph, Wrap}; @@ -97,7 +97,7 @@ fn edition_detail_line(line: &str, style: Style) -> Line<'static> { match line.split_once(':') { Some((label, value)) => Line::from(vec![ - format!("{label}:").bold().style(style), + Span::styled(format!("{label}:"), style.bold()), Span::styled(value.to_owned(), style), ]), None => Line::from(Span::styled(line.to_owned(), style)), diff --git a/src/ui/readarr_ui/library/edition_details_ui_tests.rs b/src/ui/readarr_ui/library/edition_details_ui_tests.rs index 8d9a94d5..86343e50 100644 --- a/src/ui/readarr_ui/library/edition_details_ui_tests.rs +++ b/src/ui/readarr_ui/library/edition_details_ui_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { use pretty_assertions::assert_eq; + use ratatui::style::Modifier; use ratatui::text::Line; use strum::IntoEnumIterator; @@ -58,6 +59,15 @@ mod tests { assert_eq!(span_contents(&line), vec!["Title:", " Test Edition"]); } + #[test] + fn test_edition_detail_line_renders_the_label_in_bold() { + let line = edition_detail_line("Title: Test Edition", unmonitored_style()); + + assert!(line.spans[0].style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(line.spans[0].style.fg, unmonitored_style().fg); + assert!(!line.spans[1].style.add_modifier.contains(Modifier::BOLD)); + } + #[test] fn test_edition_detail_line_keeps_colons_within_a_value() { let line = edition_detail_line("Release Date: 2023-01-05 15:00:00 UTC", primary_style()); diff --git a/src/ui/sonarr_ui/library/episode_details_ui.rs b/src/ui/sonarr_ui/library/episode_details_ui.rs index c1776e1b..aa69d564 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui.rs @@ -157,7 +157,7 @@ fn draw_episode_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let style = style_from_status(download, &episode); Line::from(vec![ - title.bold().style(style), + Span::styled(title, style.bold()), Span::styled(split[1..].join(":"), style), ]) }) From ab9645dee9873873bad684118250bd3a26671c43 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 16:52:39 -0600 Subject: [PATCH 079/170] feat(readarr): add the add author handler Handles the add author search input, search results, and the multi-step add prompt covering root folder, monitor, monitor new items, quality profile, metadata profile, tags, and confirmation. Selecting a search result whose foreign author id already exists in the library routes to the already-in-library popup instead of the add prompt, so a duplicate add cannot be submitted. --- .../library/add_author_handler.rs | 615 ++++++ .../library/add_author_handler_tests.rs | 1742 +++++++++++++++++ .../library/library_handler_tests.rs | 14 +- src/handlers/readarr_handlers/library/mod.rs | 11 +- 4 files changed, 2378 insertions(+), 4 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/add_author_handler.rs create mode 100644 src/handlers/readarr_handlers/library/add_author_handler_tests.rs diff --git a/src/handlers/readarr_handlers/library/add_author_handler.rs b/src/handlers/readarr_handlers/library/add_author_handler.rs new file mode 100644 index 00000000..3510ac52 --- /dev/null +++ b/src/handlers/readarr_handlers/library/add_author_handler.rs @@ -0,0 +1,615 @@ +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::readarr_models::{AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult}; +use crate::models::servarr_data::readarr::modals::AddAuthorModal; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_AUTHOR_SELECTION_BLOCKS, ActiveReadarrBlock, +}; +use crate::models::{BlockSelectionState, Route, Scrollable}; +use crate::network::readarr_network::ReadarrEvent; +use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; + +#[cfg(test)] +#[path = "add_author_handler_tests.rs"] +mod add_author_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct AddAuthorHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl AddAuthorHandler<'_, '_> { + fn build_add_author_body(&mut self) -> AddAuthorBody { + let add_author_modal = self + .app + .data + .readarr_data + .add_author_modal + .take() + .expect("AddAuthorModal is None"); + let tags = add_author_modal.tags.text; + let AddAuthorModal { + root_folder_list, + monitor_list, + monitor_new_items_list, + quality_profile_list, + metadata_profile_list, + .. + } = add_author_modal; + let (foreign_author_id, author_name) = { + let AddAuthorSearchResult { + foreign_author_id, + author_name, + .. + } = self + .app + .data + .readarr_data + .add_searched_authors + .as_ref() + .unwrap() + .current_selection(); + (foreign_author_id.clone(), author_name.text.clone()) + }; + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *self + .app + .data + .readarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let metadata_profile = metadata_profile_list.current_selection(); + let metadata_profile_id = *self + .app + .data + .readarr_data + .metadata_profile_map + .iter() + .filter(|(_, value)| *value == metadata_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + let path = root_folder_list.current_selection().path.clone(); + let monitor = *monitor_list.current_selection(); + let monitor_new_items = *monitor_new_items_list.current_selection(); + + AddAuthorBody { + foreign_author_id, + author_name, + monitored: true, + root_folder_path: path, + quality_profile_id, + metadata_profile_id, + tags: Vec::new(), + tag_input_string: Some(tags), + add_options: AddAuthorOptions { + monitor, + monitor_new_items, + search_for_missing_books: true, + }, + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AddAuthorHandler<'a, 'b> { + fn handle(&mut self) { + let add_author_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::AddAuthorSearchResults.into()); + + if !handle_table( + self, + |app| { + app + .data + .readarr_data + .add_searched_authors + .as_mut() + .expect("add_searched_authors should be initialized") + }, + add_author_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + ADD_AUTHOR_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> AddAuthorHandler<'a, 'b> { + AddAuthorHandler { + key, + app, + active_readarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorSelectRootFolder => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorPrompt => self.app.data.readarr_data.selected_block.up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorSelectRootFolder => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorPrompt => self.app.data.readarr_data.selected_block.down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSelectRootFolder => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSearchInput => self + .app + .data + .readarr_data + .add_author_search + .as_mut() + .unwrap() + .scroll_home(), + ActiveReadarrBlock::AddAuthorTagsInput => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .tags + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSelectRootFolder => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSearchInput => self + .app + .data + .readarr_data + .add_author_search + .as_mut() + .unwrap() + .reset_offset(), + ActiveReadarrBlock::AddAuthorTagsInput => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .tags + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorPrompt => handle_prompt_toggle(self.app, self.key), + ActiveReadarrBlock::AddAuthorSearchInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_author_search + .as_mut() + .unwrap() + ) + } + ActiveReadarrBlock::AddAuthorTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .tags + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSearchInput + if !self + .app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .text + .is_empty() => + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::AddAuthorSearchResults + if self.app.data.readarr_data.add_searched_authors.is_some() => + { + let foreign_author_id = self + .app + .data + .readarr_data + .add_searched_authors + .as_ref() + .unwrap() + .current_selection() + .foreign_author_id + .clone(); + + if self + .app + .data + .readarr_data + .authors + .items + .iter() + .any(|author| author.foreign_author_id == foreign_author_id) + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddAuthorAlreadyInLibrary.into()); + } else { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + self.app.data.readarr_data.add_author_modal = Some((&self.app.data.readarr_data).into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + } + } + ActiveReadarrBlock::AddAuthorPrompt => { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::AddAuthorConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::AddAuthor(self.build_add_author_body())); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder => self.app.push_navigation_stack( + self + .app + .data + .readarr_data + .selected_block + .get_active_block() + .into(), + ), + ActiveReadarrBlock::AddAuthorTagsInput => { + self.app.push_navigation_stack( + self + .app + .data + .readarr_data + .selected_block + .get_active_block() + .into(), + ); + self.app.ignore_special_keys_for_textbox_input = true; + } + _ => (), + } + } + ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder => self.app.pop_navigation_stack(), + ActiveReadarrBlock::AddAuthorTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSearchInput => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.add_author_search = None; + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::AddAuthorSearchResults + | ActiveReadarrBlock::AddAuthorEmptySearchResults => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.add_searched_authors = None; + self.app.ignore_special_keys_for_textbox_input = true; + } + ActiveReadarrBlock::AddAuthorPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.add_author_modal = None; + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorAlreadyInLibrary + | ActiveReadarrBlock::AddAuthorSelectRootFolder => self.app.pop_navigation_stack(), + ActiveReadarrBlock::AddAuthorTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSearchInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_author_search + .as_mut() + .unwrap() + ) + } + ActiveReadarrBlock::AddAuthorTagsInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .tags + ) + } + ActiveReadarrBlock::AddAuthorPrompt + if self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::AddAuthorConfirmPrompt + && matches_key!(confirm, key) => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::AddAuthor(self.build_add_author_body())); + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/add_author_handler_tests.rs b/src/handlers/readarr_handlers/library/add_author_handler_tests.rs new file mode 100644 index 00000000..e76ef7e9 --- /dev/null +++ b/src/handlers/readarr_handlers/library/add_author_handler_tests.rs @@ -0,0 +1,1742 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use std::sync::atomic::Ordering; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_modal_present; + use crate::assert_navigation_popped; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::add_author_handler::AddAuthorHandler; + use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, MonitorType, + NewItemMonitorType, + }; + use crate::models::servarr_data::readarr::modals::AddAuthorModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_AUTHOR_SELECTION_BLOCKS, ActiveReadarrBlock, + }; + use crate::models::servarr_models::RootFolder; + use crate::models::stateful_table::StatefulTable; + use crate::models::{BlockSelectionState, HorizontallyScrollableText}; + use crate::simple_stateful_iterable_vec; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::ADD_AUTHOR_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_add_author_select_monitor_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_vec = Vec::from_iter(MonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_vec.len()).rev() { + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[i] + ); + } + } else { + for i in 0..monitor_vec.len() { + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[(i + 1) % monitor_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_author_select_monitor_new_items_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_new_items_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(monitor_new_items_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_new_items_vec.len()).rev() { + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_new_items_vec[i] + ); + } + } else { + for i in 0..monitor_new_items_vec.len() { + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_new_items_vec[(i + 1) % monitor_new_items_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_author_select_quality_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 2" + ); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_add_author_select_metadata_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 2" + ); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_add_author_select_root_folder_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .set_items(simple_stateful_iterable_vec!(RootFolder, String, path)); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 2" + ); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 1" + ); + } + + #[rstest] + fn test_add_author_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + AddAuthorHandler::new(key, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectRootFolder + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + ); + } + } + + #[rstest] + fn test_add_author_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = true; + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + AddAuthorHandler::new(key, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectMonitor + ); + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_eq; + + use crate::extended_stateful_iterable_vec; + + use super::*; + + #[test] + fn test_add_author_select_monitor_home_end() { + let monitor_vec = Vec::from_iter(MonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_vec.clone()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[monitor_vec.len() - 1] + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[0] + ); + } + + #[test] + fn test_add_author_select_monitor_new_items_home_end() { + let monitor_new_items_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(monitor_new_items_vec.clone()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_new_items_vec[monitor_new_items_vec.len() - 1] + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_new_items_vec[0] + ); + } + + #[test] + fn test_add_author_select_quality_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 3" + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_add_author_select_metadata_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 3" + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_add_author_select_root_folder_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .set_items(extended_stateful_iterable_vec!(RootFolder, String, path)); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 3" + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 1" + ); + } + + #[test] + fn test_add_author_search_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.data.readarr_data.add_author_search = Some("Test".into()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_author_tags_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal { + tags: "Test".into(), + ..AddAuthorModal::default() + }); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + 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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + AddAuthorHandler::new(key, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + AddAuthorHandler::new(key, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_add_author_search_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.data.readarr_data.add_author_search = Some("Test".into()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_author_tags_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal { + tags: "Test".into(), + ..AddAuthorModal::default() + }); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use super::*; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + use rstest::rstest; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_add_author_search_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.add_author_search = Some("test".into()); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_pushed!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + } + + #[test] + fn test_add_author_search_input_submit_noop_on_empty_search() { + let mut app = App::test_default(); + app.data.readarr_data.add_author_search = Some(HorizontallyScrollableText::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.ignore_special_keys_for_textbox_input = true; + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert!(app.ignore_special_keys_for_textbox_input); + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorSearchInput.into() + ); + } + + #[test] + fn test_add_author_search_results_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app.data.readarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.readarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorPrompt.into() + ); + assert_modal_present!(app.data.readarr_data.add_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectRootFolder + ); + } + + #[test] + fn test_add_author_search_results_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorSearchResults.into() + ); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + + #[test] + fn test_add_author_search_results_submit_does_nothing_on_empty_table() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorSearchResults.into() + ); + } + + #[test] + fn test_add_author_search_results_submit_author_already_in_library() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app + .data + .readarr_data + .authors + .set_items(vec![author(SEARCHED_FOREIGN_AUTHOR_ID)]); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AddAuthorAlreadyInLibrary.into()); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + + #[test] + fn test_add_author_search_results_submit_author_not_in_library() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app + .data + .readarr_data + .authors + .set_items(vec![author(OTHER_FOREIGN_AUTHOR_ID)]); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AddAuthorPrompt.into()); + assert_modal_present!(app.data.readarr_data.add_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectRootFolder + ); + } + + #[test] + fn test_add_author_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_AUTHOR_SELECTION_BLOCKS.len() - 1); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_add_author_confirm_prompt_prompt_confirm_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_AUTHOR_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.prompt_confirm = true; + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app.data.readarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.readarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal { + tags: "usenet, testing".into(), + ..AddAuthorModal::default() + }); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["Test".to_owned()]); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec!["Test".to_owned()]); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .set_items(vec![RootFolder { + path: "/books".to_owned(), + ..RootFolder::default() + }]); + let expected_add_author_body = AddAuthorBody { + foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_string(), + author_name: "Test Author".to_string(), + monitored: true, + root_folder_path: "/books".to_string(), + quality_profile_id: 1, + metadata_profile_id: 1, + tags: Vec::default(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddAuthorOptions { + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + search_for_missing_books: true, + }, + }; + let expected_readarr_event = ReadarrEvent::AddAuthor(expected_add_author_body); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_readarr_event + ); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + + #[rstest] + #[case(ActiveReadarrBlock::AddAuthorSelectRootFolder, 0)] + #[case(ActiveReadarrBlock::AddAuthorSelectMonitor, 1)] + #[case(ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, 2)] + #[case(ActiveReadarrBlock::AddAuthorSelectQualityProfile, 3)] + #[case(ActiveReadarrBlock::AddAuthorSelectMetadataProfile, 4)] + #[case(ActiveReadarrBlock::AddAuthorTagsInput, 5)] + fn test_add_author_prompt_selected_block_submit( + #[case] block: ActiveReadarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_pushed!(app, block.into()); + } + + #[rstest] + fn test_add_author_prompt_selecting_preferences_blocks_submit( + #[values( + ActiveReadarrBlock::AddAuthorSelectRootFolder, + ActiveReadarrBlock::AddAuthorSelectMonitor, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + ActiveReadarrBlock::AddAuthorTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AddAuthorHandler::new(SUBMIT_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorPrompt.into()); + + if active_readarr_block == ActiveReadarrBlock::AddAuthorTagsInput { + assert!(!app.ignore_special_keys_for_textbox_input); + } + } + } + + mod test_handle_esc { + use rstest::rstest; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_add_author_search_input_esc(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.data.readarr_data.add_author_search = Some("test".into()); + app.ignore_special_keys_for_textbox_input = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + + AddAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_modal_absent!(app.data.readarr_data.add_author_search); + } + + #[rstest] + fn test_add_author_search_results_esc( + #[values( + ActiveReadarrBlock::AddAuthorSearchResults, + ActiveReadarrBlock::AddAuthorEmptySearchResults + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.push_navigation_stack(active_readarr_block.into()); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + + AddAuthorHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchInput.into()); + assert_modal_absent!(app.data.readarr_data.add_searched_authors); + assert!(app.ignore_special_keys_for_textbox_input); + } + + #[test] + fn test_add_author_already_in_library_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorAlreadyInLibrary.into()); + + AddAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorAlreadyInLibrary, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + } + + #[test] + fn test_add_author_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app.data.readarr_data.prompt_confirm = true; + + AddAuthorHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_add_author_tags_input_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorTagsInput.into()); + app.ignore_special_keys_for_textbox_input = true; + + AddAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorPrompt.into()); + assert!(!app.ignore_special_keys_for_textbox_input); + } + + #[rstest] + fn test_add_author_selecting_preferences_blocks_esc( + #[values( + ActiveReadarrBlock::AddAuthorSelectMonitor, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + ActiveReadarrBlock::AddAuthorSelectRootFolder + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + + AddAuthorHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorPrompt.into()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_str_eq; + + #[test] + fn test_add_author_search_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_search = Some("Test".into()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .text, + "Tes" + ); + } + + #[test] + fn test_add_author_search_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_search = Some(HorizontallyScrollableText::default()); + + AddAuthorHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .text, + "a" + ); + } + + #[test] + fn test_add_author_tags_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal { + tags: "Test".into(), + ..AddAuthorModal::default() + }); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_add_author_tags_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + + AddAuthorHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .text, + "a" + ); + } + + #[test] + fn test_add_author_confirm_prompt_confirm_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_AUTHOR_SELECTION_BLOCKS.len() - 1); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app.data.readarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.readarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + let mut add_author_modal = AddAuthorModal { + tags: "usenet, testing".into(), + ..AddAuthorModal::default() + }; + add_author_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_author_modal + .quality_profile_list + .set_items(vec!["Test".to_owned()]); + add_author_modal + .metadata_profile_list + .set_items(vec!["Test".to_owned()]); + add_author_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_author_modal.root_folder_list.state.select(Some(1)); + app.data.readarr_data.add_author_modal = Some(add_author_modal); + let expected_add_author_body = AddAuthorBody { + foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_string(), + author_name: "Test Author".to_string(), + monitored: true, + root_folder_path: "/nfs2".to_string(), + quality_profile_id: 1, + metadata_profile_id: 1, + tags: Vec::default(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddAuthorOptions { + monitor: Default::default(), + monitor_new_items: Default::default(), + search_for_missing_books: true, + }, + }; + let expected_readarr_event = ReadarrEvent::AddAuthor(expected_add_author_body); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_readarr_event + ); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + } + + #[test] + fn test_add_author_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|readarr_block| { + if ADD_AUTHOR_BLOCKS.contains(&readarr_block) { + assert!( + AddAuthorHandler::accepts(readarr_block), + "{readarr_block} is not accepted by the AddAuthorHandler" + ); + } else { + assert!(!AddAuthorHandler::accepts(readarr_block)); + } + }); + } + + #[rstest] + fn test_add_author_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_add_author_search_no_panic_on_none_search_result() { + let mut app = App::test_default(); + app.data.readarr_data.add_searched_authors = None; + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + } + + #[test] + fn test_add_author_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_add_author_handler_is_ready_when_not_loading() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_build_add_author_body() { + let mut app = App::test_default(); + let mut add_author_modal = AddAuthorModal { + tags: "usenet, testing".into(), + ..AddAuthorModal::default() + }; + add_author_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_author_modal.root_folder_list.state.select(Some(1)); + add_author_modal + .quality_profile_list + .set_items(vec!["Any".to_owned()]); + add_author_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned()]); + add_author_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app.data.readarr_data.add_author_modal = Some(add_author_modal); + app.data.readarr_data.quality_profile_map = BiMap::from_iter([(1, "Any".to_owned())]); + app.data.readarr_data.metadata_profile_map = BiMap::from_iter([(1, "Standard".to_owned())]); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + let expected_add_author_body = AddAuthorBody { + foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_string(), + author_name: "Test Author".into(), + monitored: true, + root_folder_path: "/nfs2".to_string(), + quality_profile_id: 1, + metadata_profile_id: 1, + tags: Vec::default(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddAuthorOptions { + monitor: Default::default(), + monitor_new_items: Default::default(), + search_for_missing_books: true, + }, + }; + + let add_author_body = AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .build_add_author_body(); + + assert_eq!(add_author_body, expected_add_author_body); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + + const SEARCHED_FOREIGN_AUTHOR_ID: &str = "aaaa-1111"; + const OTHER_FOREIGN_AUTHOR_ID: &str = "bbbb-2222"; + + fn add_author_search_result() -> AddAuthorSearchResult { + AddAuthorSearchResult { + foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_owned(), + author_name: "Test Author".into(), + status: AuthorStatus::Continuing, + ..AddAuthorSearchResult::default() + } + } + + fn author(foreign_author_id: &str) -> Author { + Author { + id: 1, + author_name: "Test Author".into(), + foreign_author_id: foreign_author_id.to_owned(), + status: AuthorStatus::Continuing, + ..Author::default() + } + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index f474ecd6..651b2e36 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -14,8 +14,8 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, - LIBRARY_BLOCKS, + ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; use crate::test_handler_delegation; @@ -889,6 +889,15 @@ mod tests { ); } + #[test] + fn test_delegates_add_author_blocks_to_add_author_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::AddAuthorSearchInput + ); + } + #[test] fn test_library_handler_accepts() { let mut library_handler_blocks = Vec::new(); @@ -896,6 +905,7 @@ mod tests { library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS); library_handler_blocks.extend(BOOK_DETAILS_BLOCKS); library_handler_blocks.extend(EDITION_DETAILS_BLOCKS); + library_handler_blocks.extend(ADD_AUTHOR_BLOCKS); ActiveReadarrBlock::iter().for_each(|readarr_block| { if library_handler_blocks.contains(&readarr_block) { diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index d4093bae..ce6144ba 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -19,12 +19,14 @@ use super::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::models::Route; +mod add_author_handler; mod author_details_handler; mod book_details_handler; mod edition_details_handler; pub(in crate::handlers::readarr_handlers) use { - author_details_handler::AuthorDetailsHandler, book_details_handler::BookDetailsHandler, + add_author_handler::AddAuthorHandler, author_details_handler::AuthorDetailsHandler, + book_details_handler::BookDetailsHandler, }; #[cfg(test)] @@ -63,6 +65,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, authors_table_handling_config, ) { match self.active_readarr_block { + _ if AddAuthorHandler::accepts(self.active_readarr_block) => { + AddAuthorHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } _ if AuthorDetailsHandler::accepts(self.active_readarr_block) => { AuthorDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); @@ -77,7 +83,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, } fn accepts(active_block: ActiveReadarrBlock) -> bool { - AuthorDetailsHandler::accepts(active_block) + AddAuthorHandler::accepts(active_block) + || AuthorDetailsHandler::accepts(active_block) || BookDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } From dc4b11cf1b9180d44f911cbc0fdceca10e7d22a0 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 17:07:53 -0600 Subject: [PATCH 080/170] feat(readarr): add the edit author handler Handles the multi-step edit prompt covering monitored, monitor new items, quality profile, metadata profile, path, tags, and confirmation. The prompt can be opened from either the author list or the author details view, so the originating block is threaded through to the sub-block pushes and Esc returns to the view the user came from. --- .../library/edit_author_handler.rs | 454 ++++++ .../library/edit_author_handler_tests.rs | 1360 +++++++++++++++++ .../library/library_handler_tests.rs | 12 +- src/handlers/readarr_handlers/library/mod.rs | 8 +- 4 files changed, 1832 insertions(+), 2 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/edit_author_handler.rs create mode 100644 src/handlers/readarr_handlers/library/edit_author_handler_tests.rs diff --git a/src/handlers/readarr_handlers/library/edit_author_handler.rs b/src/handlers/readarr_handlers/library/edit_author_handler.rs new file mode 100644 index 00000000..7d1eb05b --- /dev/null +++ b/src/handlers/readarr_handlers/library/edit_author_handler.rs @@ -0,0 +1,454 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::readarr_models::EditAuthorParams; +use crate::models::servarr_data::readarr::modals::EditAuthorModal; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS}; +use crate::models::{Route, Scrollable}; +use crate::network::readarr_network::ReadarrEvent; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; + +#[cfg(test)] +#[path = "edit_author_handler_tests.rs"] +mod edit_author_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct EditAuthorHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl EditAuthorHandler<'_, '_> { + fn build_edit_author_params(&mut self) -> EditAuthorParams { + let edit_author_modal = self + .app + .data + .readarr_data + .edit_author_modal + .take() + .expect("EditAuthorModal is None"); + let author_id = self.app.data.readarr_data.authors.current_selection().id; + let tags = edit_author_modal.tags.text; + + let EditAuthorModal { + monitored, + path, + monitor_list, + quality_profile_list, + metadata_profile_list, + .. + } = edit_author_modal; + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *self + .app + .data + .readarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let metadata_profile = metadata_profile_list.current_selection(); + let metadata_profile_id = *self + .app + .data + .readarr_data + .metadata_profile_map + .iter() + .filter(|(_, value)| *value == metadata_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + EditAuthorParams { + author_id, + monitored, + monitor_new_items: Some(*monitor_list.current_selection()), + quality_profile_id: Some(quality_profile_id), + metadata_profile_id: Some(metadata_profile_id), + root_folder_path: Some(path.text), + tag_input_string: Some(tags), + ..EditAuthorParams::default() + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for EditAuthorHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + EDIT_AUTHOR_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> EditAuthorHandler<'a, 'b> { + EditAuthorHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.readarr_data.edit_author_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_up(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_up(), + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_up(), + ActiveReadarrBlock::EditAuthorPrompt => self.app.data.readarr_data.selected_block.up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_down(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_down(), + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_down(), + ActiveReadarrBlock::EditAuthorPrompt => self.app.data.readarr_data.selected_block.down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_top(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_top(), + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_top(), + ActiveReadarrBlock::EditAuthorPathInput => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .path + .scroll_home(), + ActiveReadarrBlock::EditAuthorTagsInput => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_bottom(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::EditAuthorPathInput => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .path + .reset_offset(), + ActiveReadarrBlock::EditAuthorTagsInput => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorPrompt => handle_prompt_toggle(self.app, self.key), + ActiveReadarrBlock::EditAuthorPathInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveReadarrBlock::EditAuthorTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorPrompt => { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::EditAuthorConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::EditAuthor(self.build_edit_author_params())); + self.app.should_refresh = true; + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems + | ActiveReadarrBlock::EditAuthorSelectQualityProfile + | ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ), + ActiveReadarrBlock::EditAuthorPathInput | ActiveReadarrBlock::EditAuthorTagsInput => { + self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ); + self.app.ignore_special_keys_for_textbox_input = true; + } + ActiveReadarrBlock::EditAuthorToggleMonitored => { + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitored = Some( + !self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitored + .unwrap_or_default(), + ) + } + _ => (), + } + } + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems + | ActiveReadarrBlock::EditAuthorSelectQualityProfile + | ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self.app.pop_navigation_stack(), + ActiveReadarrBlock::EditAuthorPathInput | ActiveReadarrBlock::EditAuthorTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorTagsInput | ActiveReadarrBlock::EditAuthorPathInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::EditAuthorPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.edit_author_modal = None; + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems + | ActiveReadarrBlock::EditAuthorSelectQualityProfile + | ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorPathInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveReadarrBlock::EditAuthorTagsInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + ) + } + ActiveReadarrBlock::EditAuthorPrompt + if self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::EditAuthorConfirmPrompt + && matches_key!(confirm, key) => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::EditAuthor(self.build_edit_author_params())); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs b/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs new file mode 100644 index 00000000..e22b0bd9 --- /dev/null +++ b/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs @@ -0,0 +1,1360 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::edit_author_handler::EditAuthorHandler; + use crate::models::readarr_models::{Author, EditAuthorParams, NewItemMonitorType}; + use crate::models::servarr_data::readarr::modals::EditAuthorModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS, + }; + use crate::network::readarr_network::ReadarrEvent; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::EDIT_AUTHOR_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_edit_author_select_monitor_new_items_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_type_vec.len()).rev() { + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[i] + ); + } + } else { + for i in 0..monitor_type_vec.len() { + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[(i + 1) % monitor_type_vec.len()] + ); + } + } + } + + #[rstest] + fn test_edit_author_select_quality_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 2" + ); + + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_edit_author_select_metadata_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 2" + ); + + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_edit_author_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + EditAuthorHandler::new(key, &mut app, ActiveReadarrBlock::EditAuthorPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditAuthorToggleMonitored + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile + ); + } + } + + #[rstest] + fn test_edit_author_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + EditAuthorHandler::new(key, &mut app, ActiveReadarrBlock::EditAuthorPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems + ); + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_eq; + use std::sync::atomic::Ordering; + + use strum::IntoEnumIterator; + + use super::*; + + #[test] + fn test_edit_author_select_monitor_new_items_home_end() { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[monitor_type_vec.len() - 1] + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[0] + ); + } + + #[test] + fn test_edit_author_select_quality_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 3" + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_edit_author_select_metadata_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 3" + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_edit_author_path_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + path: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_author_tags_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + tags: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use std::sync::atomic::Ordering; + + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + EditAuthorHandler::new(key, &mut app, ActiveReadarrBlock::EditAuthorPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + EditAuthorHandler::new(key, &mut app, ActiveReadarrBlock::EditAuthorPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_edit_author_path_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + path: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_author_tags_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + tags: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use crate::assert_navigation_popped; + use crate::models::servarr_data::readarr::readarr_data::EDIT_AUTHOR_SELECTION_BLOCKS; + use crate::models::{BlockSelectionState, Route}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_author_path_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + path: "Test Path".into(), + ..EditAuthorModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPathInput.into()); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + } + + #[test] + fn test_edit_author_tags_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + tags: "Test Tags".into(), + ..EditAuthorModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorTagsInput.into()); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + } + + #[test] + fn test_edit_author_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_AUTHOR_SELECTION_BLOCKS.len() - 1); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_edit_author_confirm_prompt_prompt_confirmation_submit() { + let mut app = app_with_populated_edit_author_modal(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_AUTHOR_SELECTION_BLOCKS.len() - 1); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::EditAuthor(expected_edit_author_params())) + ); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + assert!(app.should_refresh); + } + + #[test] + fn test_edit_author_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditAuthorPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + } + + #[test] + fn test_edit_author_toggle_monitored_submit() { + let current_route = Route::from(( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + )); + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(current_route); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_some_eq_x!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitored, + true + ); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_some_eq_x!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitored, + false + ); + } + + #[rstest] + #[case(ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, 1)] + #[case(ActiveReadarrBlock::EditAuthorSelectQualityProfile, 2)] + #[case(ActiveReadarrBlock::EditAuthorSelectMetadataProfile, 3)] + #[case(ActiveReadarrBlock::EditAuthorPathInput, 4)] + #[case(ActiveReadarrBlock::EditAuthorTagsInput, 5)] + fn test_edit_author_prompt_selected_block_submit( + #[case] selected_block: ActiveReadarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .into(), + ); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_navigation_pushed!( + app, + (selected_block, Some(ActiveReadarrBlock::AuthorDetails)).into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + + if selected_block == ActiveReadarrBlock::EditAuthorPathInput + || selected_block == ActiveReadarrBlock::EditAuthorTagsInput + { + assert!(app.ignore_special_keys_for_textbox_input); + } + } + + #[rstest] + fn test_edit_author_prompt_selected_block_submit_no_op_when_not_ready( + #[values(1, 2, 3, 4)] y_index: usize, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .into(), + ); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.ignore_special_keys_for_textbox_input); + } + + #[rstest] + fn test_edit_author_prompt_selecting_preferences_blocks_submit( + #[values( + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + ActiveReadarrBlock::EditAuthorPathInput, + ActiveReadarrBlock::EditAuthorTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + active_readarr_block, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + + if active_readarr_block == ActiveReadarrBlock::EditAuthorPathInput + || active_readarr_block == ActiveReadarrBlock::EditAuthorTagsInput + { + assert!(!app.ignore_special_keys_for_textbox_input); + } + } + } + + mod test_handle_esc { + use crate::assert_navigation_popped; + use rstest::rstest; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_edit_author_input_esc( + #[values( + ActiveReadarrBlock::EditAuthorTagsInput, + ActiveReadarrBlock::EditAuthorPathInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.ignore_special_keys_for_textbox_input = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + EditAuthorHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_popped!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + } + + #[test] + fn test_edit_author_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + EditAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_edit_author_esc( + #[values( + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile + )] + active_readarr_block: ActiveReadarrBlock, + #[values(true, false)] is_loading: bool, + ) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(active_readarr_block.into()); + + EditAuthorHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::EDIT_AUTHOR_SELECTION_BLOCKS; + use pretty_assertions::assert_eq; + + #[test] + fn test_edit_author_path_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + path: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .text, + "Tes" + ); + } + + #[test] + fn test_edit_author_tags_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + tags: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_edit_author_path_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + EditAuthorHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .text, + "a" + ); + } + + #[test] + fn test_edit_author_tags_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + EditAuthorHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .text, + "a" + ); + } + + #[test] + fn test_edit_author_confirm_prompt_prompt_confirm() { + let mut app = app_with_populated_edit_author_modal(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_AUTHOR_SELECTION_BLOCKS.len() - 1); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::EditAuthor(expected_edit_author_params())) + ); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + assert!(app.should_refresh); + } + } + + #[test] + fn test_edit_author_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDIT_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(EditAuthorHandler::accepts(active_readarr_block)); + } else { + assert!(!EditAuthorHandler::accepts(active_readarr_block)); + } + }); + } + + #[rstest] + fn test_edit_author_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_edit_author_params() { + let mut app = app_with_populated_edit_author_modal(); + + let edit_author_params = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .build_edit_author_params(); + + assert_eq!(edit_author_params, expected_edit_author_params()); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + } + + #[test] + fn test_edit_author_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = true; + + let handler = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_author_handler_is_not_ready_when_edit_author_modal_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = false; + + let handler = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_author_handler_is_ready_when_edit_author_modal_is_some() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = false; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + let handler = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ); + + assert!(handler.is_ready()); + } + + fn app_with_populated_edit_author_modal() -> App<'static> { + let mut app = App::test_default(); + let mut edit_author_modal = EditAuthorModal { + tags: "usenet, testing".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + monitored: Some(false), + ..EditAuthorModal::default() + }; + edit_author_modal + .quality_profile_list + .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_author_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_author_modal + .monitor_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app.data.readarr_data.edit_author_modal = Some(edit_author_modal); + app.data.readarr_data.authors.set_items(vec![Author { + id: 42, + monitored: false, + ..Author::default() + }]); + app.data.readarr_data.quality_profile_map = BiMap::from_iter([ + (1111, "Lossless".to_owned()), + (2222, "HD - 1080p".to_owned()), + ]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); + + app + } + + fn expected_edit_author_params() -> EditAuthorParams { + EditAuthorParams { + author_id: 42, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1111), + metadata_profile_id: Some(3333), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditAuthorParams::default() + } + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index 651b2e36..8bc0d5f2 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -15,7 +15,7 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, - EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, + EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; use crate::test_handler_delegation; @@ -898,6 +898,15 @@ mod tests { ); } + #[test] + fn test_delegates_edit_author_blocks_to_edit_author_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::EditAuthorPrompt + ); + } + #[test] fn test_library_handler_accepts() { let mut library_handler_blocks = Vec::new(); @@ -906,6 +915,7 @@ mod tests { library_handler_blocks.extend(BOOK_DETAILS_BLOCKS); library_handler_blocks.extend(EDITION_DETAILS_BLOCKS); library_handler_blocks.extend(ADD_AUTHOR_BLOCKS); + library_handler_blocks.extend(EDIT_AUTHOR_BLOCKS); ActiveReadarrBlock::iter().for_each(|readarr_block| { if library_handler_blocks.contains(&readarr_block) { diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index ce6144ba..bce88861 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -22,11 +22,12 @@ use crate::models::Route; mod add_author_handler; mod author_details_handler; mod book_details_handler; +mod edit_author_handler; mod edition_details_handler; pub(in crate::handlers::readarr_handlers) use { add_author_handler::AddAuthorHandler, author_details_handler::AuthorDetailsHandler, - book_details_handler::BookDetailsHandler, + book_details_handler::BookDetailsHandler, edit_author_handler::EditAuthorHandler, }; #[cfg(test)] @@ -69,6 +70,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, AddAuthorHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); } + _ if EditAuthorHandler::accepts(self.active_readarr_block) => { + EditAuthorHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } _ if AuthorDetailsHandler::accepts(self.active_readarr_block) => { AuthorDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); @@ -84,6 +89,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, fn accepts(active_block: ActiveReadarrBlock) -> bool { AddAuthorHandler::accepts(active_block) + || EditAuthorHandler::accepts(active_block) || AuthorDetailsHandler::accepts(active_block) || BookDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) From 6a7f9fe0da960b31e5a29db0b5b453c463f513ce Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 17:18:48 -0600 Subject: [PATCH 081/170] feat(readarr): add the delete author handler Handles the delete author prompt, toggling whether to delete files on disk and whether to add a list exclusion before confirming. Backing out of the prompt resets both toggles, so preferences from an abandoned delete cannot leak into the next one. --- .../library/delete_author_handler.rs | 151 ++++++ .../library/delete_author_handler_tests.rs | 484 ++++++++++++++++++ .../library/library_handler_tests.rs | 12 +- src/handlers/readarr_handlers/library/mod.rs | 9 +- 4 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/delete_author_handler.rs create mode 100644 src/handlers/readarr_handlers/library/delete_author_handler_tests.rs diff --git a/src/handlers/readarr_handlers/library/delete_author_handler.rs b/src/handlers/readarr_handlers/library/delete_author_handler.rs new file mode 100644 index 00000000..8f541a4b --- /dev/null +++ b/src/handlers/readarr_handlers/library/delete_author_handler.rs @@ -0,0 +1,151 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::Route; +use crate::models::readarr_models::DeleteParams; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_BLOCKS, +}; +use crate::network::readarr_network::ReadarrEvent; + +#[cfg(test)] +#[path = "delete_author_handler_tests.rs"] +mod delete_author_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct DeleteAuthorHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl DeleteAuthorHandler<'_, '_> { + fn build_delete_author_params(&mut self) -> DeleteParams { + let id = self.app.data.readarr_data.authors.current_selection().id; + let delete_files = self.app.data.readarr_data.delete_files; + let add_import_list_exclusion = self.app.data.readarr_data.add_import_list_exclusion; + self.app.data.readarr_data.reset_delete_preferences(); + + DeleteParams { + id, + delete_files, + add_import_list_exclusion, + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for DeleteAuthorHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + DELETE_AUTHOR_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> DeleteAuthorHandler<'a, 'b> { + DeleteAuthorHandler { + key, + app, + active_readarr_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_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + self.app.data.readarr_data.selected_block.up(); + } + } + + fn handle_scroll_down(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + self.app.data.readarr_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_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + handle_prompt_toggle(self.app, self.key); + } + } + + fn handle_submit(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::DeleteAuthorConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::DeleteAuthor( + self.build_delete_author_params(), + )); + self.app.should_refresh = true; + } else { + self.app.data.readarr_data.reset_delete_preferences(); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile => { + self.app.data.readarr_data.delete_files = !self.app.data.readarr_data.delete_files; + } + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion => { + self.app.data.readarr_data.add_import_list_exclusion = + !self.app.data.readarr_data.add_import_list_exclusion; + } + _ => (), + } + } + } + + fn handle_esc(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_delete_preferences(); + self.app.data.readarr_data.prompt_confirm = false; + } + } + + fn handle_char_key_event(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt + && self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::DeleteAuthorConfirmPrompt + && matches_key!(confirm, self.key) + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::DeleteAuthor( + self.build_delete_author_params(), + )); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs b/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs new file mode 100644 index 00000000..ef7d7c7a --- /dev/null +++ b/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs @@ -0,0 +1,484 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::delete_author_handler::DeleteAuthorHandler; + use crate::models::readarr_models::{Author, DeleteParams}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_BLOCKS, + }; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_AUTHOR_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_delete_author_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + DeleteAuthorHandler::new(key, &mut app, ActiveReadarrBlock::DeleteAuthorPrompt, None) + .handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteAuthorConfirmPrompt + ); + } + } + + #[rstest] + fn test_delete_author_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + DeleteAuthorHandler::new(key, &mut app, ActiveReadarrBlock::DeleteAuthorPrompt, None) + .handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion + ); + } + } + + 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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + + DeleteAuthorHandler::new(key, &mut app, ActiveReadarrBlock::DeleteAuthorPrompt, None) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + DeleteAuthorHandler::new(key, &mut app, ActiveReadarrBlock::DeleteAuthorPrompt, None) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_AUTHOR_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_delete_author_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_AUTHOR_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = true; + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = false; + app.data.readarr_data.add_import_list_exclusion = true; + app.data.readarr_data.authors.set_items(vec![author()]); + let expected_delete_author_params = DeleteParams { + id: 42, + delete_files: false, + add_import_list_exclusion: true, + }; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_AUTHOR_SELECTION_BLOCKS.len() - 1); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteAuthor(expected_delete_author_params)) + ); + assert!(app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteAuthorPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_toggle_delete_files_submit() { + let current_route = ActiveReadarrBlock::DeleteAuthorPrompt.into(); + let mut app = App::test_default(); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_toggle_add_list_exclusion_submit() { + let current_route = ActiveReadarrBlock::DeleteAuthorPrompt.into(); + let mut app = App::test_default(); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(app.data.readarr_data.add_import_list_exclusion); + assert!(!app.data.readarr_data.delete_files); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(!app.data.readarr_data.add_import_list_exclusion); + assert!(!app.data.readarr_data.delete_files); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use crate::assert_navigation_popped; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_delete_author_prompt_esc(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = true; + + DeleteAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_AUTHOR_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + + const CONFIRM_KEY: Key = DEFAULT_KEYBINDINGS.confirm.key; + + #[test] + fn test_delete_author_confirm_prompt_prompt_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.authors.set_items(vec![author()]); + let expected_delete_author_params = DeleteParams { + id: 42, + delete_files: true, + add_import_list_exclusion: false, + }; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_AUTHOR_SELECTION_BLOCKS.len() - 1); + + DeleteAuthorHandler::new( + CONFIRM_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteAuthor(expected_delete_author_params)) + ); + assert!(app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_confirm_prompt_prompt_confirm_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.authors.set_items(vec![author()]); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_AUTHOR_SELECTION_BLOCKS.len() - 1); + + DeleteAuthorHandler::new( + CONFIRM_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteAuthorPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(app.data.readarr_data.delete_files); + } + } + + #[test] + fn test_delete_author_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DELETE_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(DeleteAuthorHandler::accepts(active_readarr_block)); + } else { + assert!(!DeleteAuthorHandler::accepts(active_readarr_block)); + } + }); + } + + #[rstest] + fn test_delete_author_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = DeleteAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_delete_author_params() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(vec![author()]); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + let expected_delete_author_params = DeleteParams { + id: 42, + delete_files: true, + add_import_list_exclusion: false, + }; + + let delete_author_params = DeleteAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .build_delete_author_params(); + + assert_eq!(delete_author_params, expected_delete_author_params); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = DeleteAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_delete_author_handler_ready_when_not_loading() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = DeleteAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ); + + assert!(handler.is_ready()); + } + + fn author() -> Author { + Author { + id: 42, + ..Author::default() + } + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index 8bc0d5f2..a6906059 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -15,7 +15,7 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, - EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, + DELETE_AUTHOR_BLOCKS, EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; use crate::test_handler_delegation; @@ -898,6 +898,15 @@ mod tests { ); } + #[test] + fn test_delegates_delete_author_blocks_to_delete_author_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::DeleteAuthorPrompt + ); + } + #[test] fn test_delegates_edit_author_blocks_to_edit_author_handler() { test_handler_delegation!( @@ -915,6 +924,7 @@ mod tests { library_handler_blocks.extend(BOOK_DETAILS_BLOCKS); library_handler_blocks.extend(EDITION_DETAILS_BLOCKS); library_handler_blocks.extend(ADD_AUTHOR_BLOCKS); + library_handler_blocks.extend(DELETE_AUTHOR_BLOCKS); library_handler_blocks.extend(EDIT_AUTHOR_BLOCKS); ActiveReadarrBlock::iter().for_each(|readarr_block| { diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index bce88861..b2b96c4f 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -22,12 +22,14 @@ use crate::models::Route; mod add_author_handler; mod author_details_handler; mod book_details_handler; +mod delete_author_handler; mod edit_author_handler; mod edition_details_handler; pub(in crate::handlers::readarr_handlers) use { add_author_handler::AddAuthorHandler, author_details_handler::AuthorDetailsHandler, - book_details_handler::BookDetailsHandler, edit_author_handler::EditAuthorHandler, + book_details_handler::BookDetailsHandler, delete_author_handler::DeleteAuthorHandler, + edit_author_handler::EditAuthorHandler, }; #[cfg(test)] @@ -70,6 +72,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, AddAuthorHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); } + _ if DeleteAuthorHandler::accepts(self.active_readarr_block) => { + DeleteAuthorHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } _ if EditAuthorHandler::accepts(self.active_readarr_block) => { EditAuthorHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); @@ -89,6 +95,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, fn accepts(active_block: ActiveReadarrBlock) -> bool { AddAuthorHandler::accepts(active_block) + || DeleteAuthorHandler::accepts(active_block) || EditAuthorHandler::accepts(active_block) || AuthorDetailsHandler::accepts(active_block) || BookDetailsHandler::accepts(active_block) From 7500e2da24246b6a8fab503182509eba1e9262c7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 17:29:31 -0600 Subject: [PATCH 082/170] feat(readarr): add the delete book handler Handles the delete book prompt opened from the author details view, toggling whether to delete files on disk and whether to add a list exclusion before confirming. Backing out of the prompt resets both toggles, so preferences from an abandoned delete cannot leak into the next one. --- .../library/delete_book_handler.rs | 147 ++++++ .../library/delete_book_handler_tests.rs | 470 ++++++++++++++++++ .../library/library_handler_tests.rs | 13 +- src/handlers/readarr_handlers/library/mod.rs | 8 +- 4 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/delete_book_handler.rs create mode 100644 src/handlers/readarr_handlers/library/delete_book_handler_tests.rs diff --git a/src/handlers/readarr_handlers/library/delete_book_handler.rs b/src/handlers/readarr_handlers/library/delete_book_handler.rs new file mode 100644 index 00000000..3ef9b49b --- /dev/null +++ b/src/handlers/readarr_handlers/library/delete_book_handler.rs @@ -0,0 +1,147 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::Route; +use crate::models::readarr_models::DeleteParams; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DELETE_BOOK_BLOCKS}; +use crate::network::readarr_network::ReadarrEvent; + +#[cfg(test)] +#[path = "delete_book_handler_tests.rs"] +mod delete_book_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct DeleteBookHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl DeleteBookHandler<'_, '_> { + fn build_delete_book_params(&mut self) -> DeleteParams { + let id = self.app.data.readarr_data.books.current_selection().id; + let delete_files = self.app.data.readarr_data.delete_files; + let add_import_list_exclusion = self.app.data.readarr_data.add_import_list_exclusion; + self.app.data.readarr_data.reset_delete_preferences(); + + DeleteParams { + id, + delete_files, + add_import_list_exclusion, + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for DeleteBookHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + DELETE_BOOK_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> DeleteBookHandler<'a, 'b> { + DeleteBookHandler { + key, + app, + active_readarr_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_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + self.app.data.readarr_data.selected_block.up(); + } + } + + fn handle_scroll_down(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + self.app.data.readarr_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_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + handle_prompt_toggle(self.app, self.key); + } + } + + fn handle_submit(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::DeleteBookConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBook(self.build_delete_book_params())); + self.app.should_refresh = true; + } else { + self.app.data.readarr_data.reset_delete_preferences(); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteBookToggleDeleteFile => { + self.app.data.readarr_data.delete_files = !self.app.data.readarr_data.delete_files; + } + ActiveReadarrBlock::DeleteBookToggleAddListExclusion => { + self.app.data.readarr_data.add_import_list_exclusion = + !self.app.data.readarr_data.add_import_list_exclusion; + } + _ => (), + } + } + } + + fn handle_esc(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_delete_preferences(); + self.app.data.readarr_data.prompt_confirm = false; + } + } + + fn handle_char_key_event(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt + && self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::DeleteBookConfirmPrompt + && matches_key!(confirm, self.key) + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBook(self.build_delete_book_params())); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs b/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs new file mode 100644 index 00000000..064e2dee --- /dev/null +++ b/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs @@ -0,0 +1,470 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::delete_book_handler::DeleteBookHandler; + use crate::models::readarr_models::{Book, DeleteParams}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_BOOK_BLOCKS, + }; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_BOOK_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_delete_book_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + DeleteBookHandler::new(key, &mut app, ActiveReadarrBlock::DeleteBookPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteBookToggleDeleteFile + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteBookConfirmPrompt + ); + } + } + + #[rstest] + fn test_delete_book_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + DeleteBookHandler::new(key, &mut app, ActiveReadarrBlock::DeleteBookPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteBookToggleAddListExclusion + ); + } + } + + 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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + + DeleteBookHandler::new(key, &mut app, ActiveReadarrBlock::DeleteBookPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + DeleteBookHandler::new(key, &mut app, ActiveReadarrBlock::DeleteBookPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_BOOK_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_delete_book_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_BOOK_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = true; + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = false; + app.data.readarr_data.add_import_list_exclusion = true; + app.data.readarr_data.books.set_items(vec![book()]); + let expected_delete_book_params = DeleteParams { + id: 42, + delete_files: false, + add_import_list_exclusion: true, + }; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_BOOK_SELECTION_BLOCKS.len() - 1); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBook(expected_delete_book_params)) + ); + assert!(app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteBookPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_toggle_delete_files_submit() { + let current_route = ActiveReadarrBlock::DeleteBookPrompt.into(); + let mut app = App::test_default(); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_toggle_add_list_exclusion_submit() { + let current_route = ActiveReadarrBlock::DeleteBookPrompt.into(); + let mut app = App::test_default(); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(app.data.readarr_data.add_import_list_exclusion); + assert!(!app.data.readarr_data.delete_files); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(!app.data.readarr_data.add_import_list_exclusion); + assert!(!app.data.readarr_data.delete_files); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use crate::assert_navigation_popped; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_delete_book_prompt_esc(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = true; + + DeleteBookHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_BOOK_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + + const CONFIRM_KEY: Key = DEFAULT_KEYBINDINGS.confirm.key; + + #[test] + fn test_delete_book_confirm_prompt_prompt_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.books.set_items(vec![book()]); + let expected_delete_book_params = DeleteParams { + id: 42, + delete_files: true, + add_import_list_exclusion: false, + }; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_BOOK_SELECTION_BLOCKS.len() - 1); + + DeleteBookHandler::new( + CONFIRM_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBook(expected_delete_book_params)) + ); + assert!(app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_confirm_prompt_prompt_confirm_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.books.set_items(vec![book()]); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_BOOK_SELECTION_BLOCKS.len() - 1); + + DeleteBookHandler::new( + CONFIRM_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteBookPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(app.data.readarr_data.delete_files); + } + } + + #[test] + fn test_delete_book_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DELETE_BOOK_BLOCKS.contains(&active_readarr_block) { + assert!(DeleteBookHandler::accepts(active_readarr_block)); + } else { + assert!(!DeleteBookHandler::accepts(active_readarr_block)); + } + }); + } + + #[rstest] + fn test_delete_book_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = DeleteBookHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_delete_book_params() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(vec![book()]); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + let expected_delete_book_params = DeleteParams { + id: 42, + delete_files: true, + add_import_list_exclusion: false, + }; + + let delete_book_params = DeleteBookHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .build_delete_book_params(); + + assert_eq!(delete_book_params, expected_delete_book_params); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = DeleteBookHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_delete_book_handler_ready_when_not_loading() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = DeleteBookHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ); + + assert!(handler.is_ready()); + } + + fn book() -> Book { + Book { + id: 42, + ..Book::default() + } + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index a6906059..5f215732 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -15,7 +15,8 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, - DELETE_AUTHOR_BLOCKS, EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, + DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, + LIBRARY_BLOCKS, }; use crate::test_handler_delegation; @@ -907,6 +908,15 @@ mod tests { ); } + #[test] + fn test_delegates_delete_book_blocks_to_delete_book_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::DeleteBookPrompt + ); + } + #[test] fn test_delegates_edit_author_blocks_to_edit_author_handler() { test_handler_delegation!( @@ -925,6 +935,7 @@ mod tests { library_handler_blocks.extend(EDITION_DETAILS_BLOCKS); library_handler_blocks.extend(ADD_AUTHOR_BLOCKS); library_handler_blocks.extend(DELETE_AUTHOR_BLOCKS); + library_handler_blocks.extend(DELETE_BOOK_BLOCKS); library_handler_blocks.extend(EDIT_AUTHOR_BLOCKS); ActiveReadarrBlock::iter().for_each(|readarr_block| { diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index b2b96c4f..cfc03e5f 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -23,13 +23,14 @@ mod add_author_handler; mod author_details_handler; mod book_details_handler; mod delete_author_handler; +mod delete_book_handler; mod edit_author_handler; mod edition_details_handler; pub(in crate::handlers::readarr_handlers) use { add_author_handler::AddAuthorHandler, author_details_handler::AuthorDetailsHandler, book_details_handler::BookDetailsHandler, delete_author_handler::DeleteAuthorHandler, - edit_author_handler::EditAuthorHandler, + delete_book_handler::DeleteBookHandler, edit_author_handler::EditAuthorHandler, }; #[cfg(test)] @@ -84,6 +85,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, AuthorDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); } + _ if DeleteBookHandler::accepts(self.active_readarr_block) => { + DeleteBookHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } _ if BookDetailsHandler::accepts(self.active_readarr_block) => { BookDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); @@ -96,6 +101,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, fn accepts(active_block: ActiveReadarrBlock) -> bool { AddAuthorHandler::accepts(active_block) || DeleteAuthorHandler::accepts(active_block) + || DeleteBookHandler::accepts(active_block) || EditAuthorHandler::accepts(active_block) || AuthorDetailsHandler::accepts(active_block) || BookDetailsHandler::accepts(active_block) From 7d1ff86c68ba594eff35d5bd7437214bfb103095 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 18:03:12 -0600 Subject: [PATCH 083/170] feat(readarr): add the add author UI Renders the author search box, the search results table, and the multi-step add confirmation prompt with its five dropdown popups. The prompt's per-field highlighting is conveyed purely by style, which render_to_string_with_app discards, so the highlight mapping is extracted into add_author_prompt_highlights and unit tested per selection step. Enriches the fully populated author search fixture with a second result that is absent from the library, so both branches of the in-library marker render in one snapshot and every table column carries a value. --- .../servarr_data/readarr/readarr_data.rs | 21 +- src/ui/readarr_ui/library/add_author_ui.rs | 446 ++++++++++++++++++ .../readarr_ui/library/add_author_ui_tests.rs | 289 ++++++++++++ src/ui/readarr_ui/library/library_ui_tests.rs | 18 +- src/ui/readarr_ui/library/mod.rs | 7 +- ..._author_already_in_library_ui_renders.snap | 47 ++ ...active_AddAuthorSelectMetadataProfile.snap | 48 ++ ...r_modal_active_AddAuthorSelectMonitor.snap | 48 ++ ...active_AddAuthorSelectMonitorNewItems.snap | 48 ++ ..._active_AddAuthorSelectQualityProfile.snap | 48 ++ ...odal_active_AddAuthorSelectRootFolder.snap | 48 ++ ...uthor_modal_active_AddAuthorTagsInput.snap | 48 ++ ...odal_step_0_AddAuthorSelectRootFolder.snap | 48 ++ ...r_modal_step_1_AddAuthorSelectMonitor.snap | 48 ++ ...step_2_AddAuthorSelectMonitorNewItems.snap | 48 ++ ..._step_3_AddAuthorSelectQualityProfile.snap | 48 ++ ...step_4_AddAuthorSelectMetadataProfile.snap | 48 ++ ...uthor_modal_step_5_AddAuthorTagsInput.snap | 48 ++ ...r_modal_step_6_AddAuthorConfirmPrompt.snap | 48 ++ ...author_ui_AddAuthorEmptySearchResults.snap | 47 ++ ...s__add_author_ui_AddAuthorSearchInput.snap | 47 ++ ..._add_author_ui_AddAuthorSearchResults.snap | 47 ++ ..._author_ui_renders_loading_for_search.snap | 47 ++ ...ry_ui_renders_add_author_over_library.snap | 47 ++ 24 files changed, 1682 insertions(+), 5 deletions(-) create mode 100644 src/ui/readarr_ui/library/add_author_ui.rs create mode 100644 src/ui/readarr_ui/library/add_author_ui_tests.rs create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_already_in_library_ui_renders.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorEmptySearchResults.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchInput.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchResults.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_renders_loading_for_search.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index 30ad8344..27a37483 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -30,7 +30,7 @@ use { super::modals::EditionDetailsModal, crate::models::readarr_models::{ AuthorStatus, BookFile, DownloadStatus, Edition, MediaInfo, MonitorType, NewItemMonitorType, - ReadarrHistoryEventType, ReadarrTaskName, + Ratings, ReadarrHistoryEventType, ReadarrTaskName, }, crate::models::servarr_models::{IndexerField, Quality, QualityWrapper}, crate::models::stateful_table::SortOption, @@ -378,9 +378,26 @@ impl ReadarrData<'_> { author_name: "Test Author".into(), status: AuthorStatus::Continuing, overview: Some("some interesting description of the author".to_owned()), + author_type: Some("Person".to_owned()), + disambiguation: Some("American novelist".to_owned()), + ratings: Some(Ratings { + votes: 1024, + value: 4.5, + popularity: Some(9.75), + }), genres: vec!["science fiction".to_owned()], ..AddAuthorSearchResult::default() }; + let unadded_author_search_result = AddAuthorSearchResult { + foreign_author_id: "unadded-foreign-id".to_owned(), + author_name: "Unadded Author".into(), + status: AuthorStatus::Ended, + ended: true, + overview: Some("an author who is not yet in the library".to_owned()), + author_type: Some("Group".to_owned()), + genres: vec!["fantasy".to_owned(), "horror".to_owned()], + ..AddAuthorSearchResult::default() + }; let task = ReadarrTask { name: "Backup".to_owned(), task_name: ReadarrTaskName::Backup, @@ -589,7 +606,7 @@ impl ReadarrData<'_> { readarr_data.queued_events.set_items(vec![queued_event()]); readarr_data.add_author_search = Some("Test Author".into()); let mut add_searched_authors = StatefulTable::default(); - add_searched_authors.set_items(vec![add_author_search_result]); + add_searched_authors.set_items(vec![add_author_search_result, unadded_author_search_result]); readarr_data.add_searched_authors = Some(add_searched_authors); readarr_data.logs.set_items(vec![log_line.into()]); readarr_data.log_details.set_items(vec![log_line.into()]); diff --git a/src/ui/readarr_ui/library/add_author_ui.rs b/src/ui/readarr_ui/library/add_author_ui.rs new file mode 100644 index 00000000..ff6bab80 --- /dev/null +++ b/src/ui/readarr_ui/library/add_author_ui.rs @@ -0,0 +1,446 @@ +use std::sync::atomic::Ordering; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::widgets::{Cell, ListItem, Row}; + +use crate::App; +use crate::models::Route; +use crate::models::readarr_models::AddAuthorSearchResult; +use crate::models::servarr_data::readarr::modals::AddAuthorModal; +use crate::models::servarr_data::readarr::readarr_data::{ADD_AUTHOR_BLOCKS, ActiveReadarrBlock}; +use crate::render_selectable_input_box; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, layout_block, title_block_centered}; +use crate::ui::widgets::button::Button; +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::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "add_author_ui_tests.rs"] +mod add_author_ui_tests; + +pub(super) struct AddAuthorUi; + +impl DrawUi for AddAuthorUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + ADD_AUTHOR_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_popup(f, app, draw_add_author_search, Size::Large); + + match active_readarr_block { + ActiveReadarrBlock::AddAuthorPrompt + | ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder + | ActiveReadarrBlock::AddAuthorTagsInput => { + draw_popup(f, app, draw_confirmation_popup, Size::Long); + } + ActiveReadarrBlock::AddAuthorAlreadyInLibrary => { + f.render_widget( + Popup::new(Message::new("This author is already in your library")).size(Size::Message), + f.area(), + ); + } + _ => (), + } + } + } +} + +fn build_add_author_prompt_title(author_name: &str, disambiguation: &str) -> String { + if disambiguation.is_empty() { + format!("Add - {author_name}") + } else { + format!("Add - {author_name} ({disambiguation})") + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct AddAuthorPromptHighlights { + root_folder: bool, + monitor: bool, + monitor_new_items: bool, + quality_profile: bool, + metadata_profile: bool, + tags: bool, + confirm: bool, +} + +fn add_author_prompt_highlights(selected_block: ActiveReadarrBlock) -> AddAuthorPromptHighlights { + AddAuthorPromptHighlights { + root_folder: selected_block == ActiveReadarrBlock::AddAuthorSelectRootFolder, + monitor: selected_block == ActiveReadarrBlock::AddAuthorSelectMonitor, + monitor_new_items: selected_block == ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + quality_profile: selected_block == ActiveReadarrBlock::AddAuthorSelectQualityProfile, + metadata_profile: selected_block == ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + tags: selected_block == ActiveReadarrBlock::AddAuthorTagsInput, + confirm: selected_block == ActiveReadarrBlock::AddAuthorConfirmPrompt, + } +} + +fn already_in_library_marker(is_in_library: bool) -> &'static str { + if is_in_library { "✔" } else { "" } +} + +fn draw_add_author_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.readarr_data.add_searched_authors.is_none(); + let current_selection = if let Some(add_searched_authors) = + app.data.readarr_data.add_searched_authors.as_ref() + && !add_searched_authors.is_empty() + { + add_searched_authors.current_selection().clone() + } else { + AddAuthorSearchResult::default() + }; + + let [search_box_area, results_area] = + Layout::vertical([Constraint::Length(3), Constraint::Fill(0)]) + .margin(1) + .areas(area); + let block_content = &app + .data + .readarr_data + .add_author_search + .as_ref() + .expect("add_author_search must be populated") + .text; + let offset = app + .data + .readarr_data + .add_author_search + .as_ref() + .expect("add_author_search must be populated") + .offset + .load(Ordering::SeqCst); + + let search_results_row_mapping = |author: &AddAuthorSearchResult| { + let rating = author + .ratings + .as_ref() + .map_or(String::new(), |r| format!("{:.1}", r.value)); + let in_library = already_in_library_marker( + app + .data + .readarr_data + .authors + .items + .iter() + .any(|a| a.foreign_author_id == author.foreign_author_id), + ); + + author.author_name.scroll_left_or_reset( + get_width_from_percentage(area, 27), + *author == current_selection, + app.should_text_scroll, + ); + + Row::new(vec![ + Cell::from(in_library), + Cell::from(author.author_name.to_string()), + Cell::from(author.author_type.clone().unwrap_or_default()), + Cell::from(author.status.to_display_str()), + Cell::from(rating), + Cell::from(author.genres.join(", ")), + ]) + .primary() + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + match active_readarr_block { + ActiveReadarrBlock::AddAuthorSearchInput => { + let search_box = InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Author")); + + search_box.show_cursor(f, search_box_area); + f.render_widget(layout_block().default_color(), results_area); + f.render_widget(search_box, search_box_area); + } + ActiveReadarrBlock::AddAuthorEmptySearchResults => { + let error_message = Message::new("No authors found matching your query!"); + let error_message_popup = Popup::new(error_message).size(Size::Message); + + f.render_widget(layout_block().default_color(), results_area); + f.render_widget(error_message_popup, f.area()); + } + ActiveReadarrBlock::AddAuthorSearchResults + | ActiveReadarrBlock::AddAuthorPrompt + | ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder + | ActiveReadarrBlock::AddAuthorAlreadyInLibrary + | ActiveReadarrBlock::AddAuthorTagsInput => { + let search_results_table = ManagarrTable::new( + app.data.readarr_data.add_searched_authors.as_mut(), + search_results_row_mapping, + ) + .loading(is_loading) + .block(layout_block().default_color()) + .headers(["✔", "Name", "Type", "Status", "Rating", "Genres"]) + .constraints([ + Constraint::Percentage(3), + Constraint::Percentage(27), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(8), + Constraint::Percentage(38), + ]); + + f.render_widget(search_results_table, results_area); + } + _ => (), + } + } + + f.render_widget( + InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Author")), + search_box_area, + ); +} + +fn draw_confirmation_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + match active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_monitor_popup(f, app); + } + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_monitor_new_items_popup(f, app); + } + ActiveReadarrBlock::AddAuthorSelectQualityProfile => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_quality_profile_popup(f, app); + } + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_metadata_profile_popup(f, app); + } + ActiveReadarrBlock::AddAuthorSelectRootFolder => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_root_folder_popup(f, app); + } + ActiveReadarrBlock::AddAuthorPrompt | ActiveReadarrBlock::AddAuthorTagsInput => { + draw_confirmation_prompt(f, app, area) + } + _ => (), + } + } +} + +fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let searched_author = app + .data + .readarr_data + .add_searched_authors + .as_ref() + .expect("add_searched_authors must be populated") + .current_selection(); + let author_name = &searched_author.author_name.text; + let author_disambiguation = searched_author.disambiguation.clone().unwrap_or_default(); + + let title = build_add_author_prompt_title(author_name, &author_disambiguation); + let yes_no_value = app.data.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = add_author_prompt_highlights(selected_block); + let AddAuthorModal { + monitor_list, + monitor_new_items_list, + quality_profile_list, + metadata_profile_list, + root_folder_list, + tags, + .. + } = app + .data + .readarr_data + .add_author_modal + .as_ref() + .expect("add_author_modal must exist in this context"); + + let selected_monitor = monitor_list.current_selection(); + let selected_monitor_new_items = monitor_new_items_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_metadata_profile = metadata_profile_list.current_selection(); + let selected_root_folder = root_folder_list.current_selection(); + + f.render_widget(title_block_centered(&title), area); + + let [ + _, + root_folder_area, + monitor_area, + monitor_new_items_area, + quality_profile_area, + metadata_profile_area, + tags_area, + _, + buttons_area, + ] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + + let [add_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let root_folder_drop_down_button = Button::default() + .title(&selected_root_folder.path) + .label("Root Folder") + .icon("▼") + .selected(highlights.root_folder); + let monitor_drop_down_button = Button::default() + .title(selected_monitor.to_display_str()) + .label("Monitor") + .icon("▼") + .selected(highlights.monitor); + let monitor_new_items_drop_down_button = Button::default() + .title(selected_monitor_new_items.to_display_str()) + .label("Monitor New Items") + .icon("▼") + .selected(highlights.monitor_new_items); + let quality_profile_drop_down_button = Button::default() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(highlights.quality_profile); + let metadata_profile_drop_down_button = Button::default() + .title(selected_metadata_profile) + .label("Metadata Profile") + .icon("▼") + .selected(highlights.metadata_profile); + + f.render_widget(root_folder_drop_down_button, root_folder_area); + f.render_widget(monitor_drop_down_button, monitor_area); + f.render_widget(monitor_new_items_drop_down_button, monitor_new_items_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(metadata_profile_drop_down_button, metadata_profile_area); + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::AddAuthorTagsInput); + render_selectable_input_box!(tags_input_box, f, tags_area); + } + + let add_button = Button::default() + .title("Add") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + f.render_widget(add_button, add_area); + f.render_widget(cancel_button, cancel_area); +} + +fn draw_add_author_select_monitor_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .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_author_select_monitor_new_items_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_new_items_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .monitor_new_items_list, + |monitor_new_items| ListItem::new(monitor_new_items.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_new_items_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_author_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .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_author_select_metadata_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let metadata_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .metadata_profile_list, + |metadata_profile| ListItem::new(metadata_profile.clone()), + ); + let popup = Popup::new(metadata_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_author_select_root_folder_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let root_folder_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .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/readarr_ui/library/add_author_ui_tests.rs b/src/ui/readarr_ui/library/add_author_ui_tests.rs new file mode 100644 index 00000000..667ae129 --- /dev/null +++ b/src/ui/readarr_ui/library/add_author_ui_tests.rs @@ -0,0 +1,289 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_AUTHOR_SELECTION_BLOCKS, ActiveReadarrBlock, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::add_author_ui::{ + AddAuthorPromptHighlights, AddAuthorUi, add_author_prompt_highlights, + already_in_library_marker, build_add_author_prompt_title, + }; + + #[test] + fn test_add_author_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ADD_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(AddAuthorUi::accepts(active_readarr_block.into())); + } else { + assert!(!AddAuthorUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_build_add_author_prompt_title_without_disambiguation() { + let title = build_add_author_prompt_title("Test Author", ""); + + assert_str_eq!(title, "Add - Test Author"); + } + + #[test] + fn test_build_add_author_prompt_title_with_disambiguation() { + let title = build_add_author_prompt_title("Test Author", "American novelist"); + + assert_str_eq!(title, "Add - Test Author (American novelist)"); + } + + #[test] + fn test_already_in_library_marker_when_in_library() { + let marker = already_in_library_marker(true); + + assert_str_eq!(marker, "✔"); + } + + #[test] + fn test_already_in_library_marker_when_not_in_library() { + let marker = already_in_library_marker(false); + + assert_str_eq!(marker, ""); + } + + #[rstest] + #[case( + ActiveReadarrBlock::AddAuthorSelectRootFolder, + AddAuthorPromptHighlights { + root_folder: true, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorSelectMonitor, + AddAuthorPromptHighlights { + root_folder: false, + monitor: true, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: true, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: false, + quality_profile: true, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: true, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorTagsInput, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorConfirmPrompt, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: true, + } + )] + fn test_add_author_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: AddAuthorPromptHighlights, + ) { + let highlights = add_author_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_add_author_prompt_highlights_covers_every_selection_step() { + for step in ADD_AUTHOR_SELECTION_BLOCKS { + let highlights = add_author_prompt_highlights(step[0]); + + assert_ne!( + highlights, + add_author_prompt_highlights(ActiveReadarrBlock::Authors) + ); + } + } + + mod snapshot_tests { + use rstest::rstest; + + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::HorizontallyScrollableText; + use crate::models::servarr_data::readarr::readarr_data::ADD_AUTHOR_SELECTION_BLOCKS; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_profile_lists(app: &mut App<'_>) { + let add_author_modal = app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context"); + add_author_modal + .quality_profile_list + .set_items(vec!["eBook".to_owned()]); + add_author_modal + .metadata_profile_list + .set_items(vec!["Author Only".to_owned()]); + } + + #[test] + fn test_add_author_ui_renders_loading_for_search() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.add_author_search = Some(HorizontallyScrollableText::default()); + app.data.readarr_data.add_searched_authors = None; + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_add_author_ui_renders( + #[values( + ActiveReadarrBlock::AddAuthorSearchInput, + ActiveReadarrBlock::AddAuthorSearchResults, + ActiveReadarrBlock::AddAuthorEmptySearchResults + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("add_author_ui_{active_readarr_block}"), output); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::AddAuthorSelectRootFolder)] + #[case(1, ActiveReadarrBlock::AddAuthorSelectMonitor)] + #[case(2, ActiveReadarrBlock::AddAuthorSelectMonitorNewItems)] + #[case(3, ActiveReadarrBlock::AddAuthorSelectQualityProfile)] + #[case(4, ActiveReadarrBlock::AddAuthorSelectMetadataProfile)] + #[case(5, ActiveReadarrBlock::AddAuthorTagsInput)] + #[case(6, ActiveReadarrBlock::AddAuthorConfirmPrompt)] + fn test_add_author_modal_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("add_author_modal_step_{step}_{selected_readarr_block}"), + output + ); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::AddAuthorSelectRootFolder)] + #[case(1, ActiveReadarrBlock::AddAuthorSelectMonitor)] + #[case(2, ActiveReadarrBlock::AddAuthorSelectMonitorNewItems)] + #[case(3, ActiveReadarrBlock::AddAuthorSelectQualityProfile)] + #[case(4, ActiveReadarrBlock::AddAuthorSelectMetadataProfile)] + #[case(5, ActiveReadarrBlock::AddAuthorTagsInput)] + fn test_add_author_modal_ui_renders_active_block( + #[case] step: usize, + #[case] active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("add_author_modal_active_{active_readarr_block}"), + output + ); + } + + #[test] + fn test_add_author_already_in_library_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorAlreadyInLibrary.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs index 757ffde3..6e21d9ba 100644 --- a/src/ui/readarr_ui/library/library_ui_tests.rs +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -4,8 +4,8 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, - LIBRARY_BLOCKS, + ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; @@ -19,6 +19,7 @@ mod tests { blocks.extend(AUTHOR_DETAILS_BLOCKS); blocks.extend(BOOK_DETAILS_BLOCKS); blocks.extend(EDITION_DETAILS_BLOCKS); + blocks.extend(ADD_AUTHOR_BLOCKS); for active_readarr_block in ActiveReadarrBlock::iter() { if blocks.contains(&active_readarr_block) { @@ -265,5 +266,18 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_library_ui_renders_add_author_over_library() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.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/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index 865576af..08b0e235 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -1,3 +1,4 @@ +use add_author_ui::AddAuthorUi; use author_details_ui::AuthorDetailsUi; use ratatui::{ Frame, @@ -25,6 +26,7 @@ use crate::{ }, }; +mod add_author_ui; mod author_details_ui; mod book_details_ui; mod edition_details_ui; @@ -38,7 +40,9 @@ pub(super) struct LibraryUi; impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Readarr(active_readarr_block, _) = route { - return AuthorDetailsUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_readarr_block); + return AddAuthorUi::accepts(route) + || AuthorDetailsUi::accepts(route) + || LIBRARY_BLOCKS.contains(&active_readarr_block); } false @@ -49,6 +53,7 @@ impl DrawUi for LibraryUi { draw_library(f, app, area); match route { + _ if AddAuthorUi::accepts(route) => AddAuthorUi::draw(f, app, area), _ if AuthorDetailsUi::accepts(route) => AuthorDetailsUi::draw(f, app, area), Route::Readarr(ActiveReadarrBlock::UpdateAllAuthorsPrompt, _) => { let confirmation_prompt = ConfirmationPrompt::new() diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_already_in_library_ui_renders.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_already_in_library_ui_renders.snap new file mode 100644 index 00000000..143ccb74 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_already_in_library_ui_renders.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> ✔ Test Author Person Continuing 4.5 science fiction │ + │ Unadded Author Group Ended fantasy, horror │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────── Error ───────────────╮ │ + │ │This author is already in your library │ │ + │ │ │ │ + │ ╰───────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap new file mode 100644 index 00000000..617c0da2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ + │ │ │Author Only │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Monito│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Qual│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Metad│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap new file mode 100644 index 00000000..b08e1755 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ + │ │ │All Books │ ▼ │ │ │ + │ │ │Future Books │──────────────────────────────╯ │ │ + │ │ │Missing Books │──────────────────────────────╮ │ │ + │ │ Monito│Existing Books │ ▼ │ │ │ + │ │ │First Book │──────────────────────────────╯ │ │ + │ │ │Latest Book │──────────────────────────────╮ │ │ + │ │ Qual│None │ ▼ │ │ │ + │ │ │Unknown │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Metad│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap new file mode 100644 index 00000000..003db906 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ + │ │ │All Books │ ▼ │ │ │ + │ │ │No New Books │──────────────────────────────╯ │ │ + │ │ │New Books │──────────────────────────────╮ │ │ + │ │ Monito│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Qual│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Metad│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap new file mode 100644 index 00000000..eb0c7de1 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ + │ │ │eBook │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Monito│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Qual│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Metad│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap new file mode 100644 index 00000000..0cd8327a --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ + │ │ │/nfs │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Monito│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Qual│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Metad│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap new file mode 100644 index 00000000..0ad696bf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap new file mode 100644 index 00000000..0ad696bf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap new file mode 100644 index 00000000..0ad696bf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap new file mode 100644 index 00000000..0ad696bf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap new file mode 100644 index 00000000..0ad696bf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap new file mode 100644 index 00000000..0ad696bf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap new file mode 100644 index 00000000..0ad696bf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap new file mode 100644 index 00000000..0ad696bf --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorEmptySearchResults.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorEmptySearchResults.snap new file mode 100644 index 00000000..872a81f2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorEmptySearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────── Error ───────────────╮ │ + │ │ No authors found matching your query! │ │ + │ │ │ │ + │ ╰───────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchInput.snap new file mode 100644 index 00000000..229f00ce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchInput.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchResults.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchResults.snap new file mode 100644 index 00000000..507e5116 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> ✔ Test Author Person Continuing 4.5 science fiction │ + │ Unadded Author Group Ended fantasy, horror │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_renders_loading_for_search.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_renders_loading_for_search.snap new file mode 100644 index 00000000..9f640028 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_renders_loading_for_search.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap new file mode 100644 index 00000000..45145085 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> ✔ Test Author Person Continuing 4.5 science fiction │ + │ Unadded Author Group Ended fantasy, horror │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ From 7fa091cb44fec001dd7a9cbfdb096329cf1d5242 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 21:38:37 -0600 Subject: [PATCH 084/170] feat(readarr): add the edit author UI Renders the edit author prompt with a snapshot per step of the selection state machine, seeding distinct quality and metadata profile values so a swap between the two dropdowns cannot go unnoticed. --- src/ui/readarr_ui/library/edit_author_ui.rs | 270 ++++++++++++++++++ .../library/edit_author_ui_tests.rs | 266 +++++++++++++++++ src/ui/readarr_ui/library/library_ui_tests.rs | 22 +- src/ui/readarr_ui/library/mod.rs | 4 + ...dit_author_active_EditAuthorPathInput.snap | 48 ++++ ...ctive_EditAuthorSelectMetadataProfile.snap | 48 ++++ ...ctive_EditAuthorSelectMonitorNewItems.snap | 48 ++++ ...active_EditAuthorSelectQualityProfile.snap | 48 ++++ ...dit_author_active_EditAuthorTagsInput.snap | 48 ++++ ...thor_step_0_EditAuthorToggleMonitored.snap | 48 ++++ ...tep_1_EditAuthorSelectMonitorNewItems.snap | 48 ++++ ...step_2_EditAuthorSelectQualityProfile.snap | 48 ++++ ...tep_3_EditAuthorSelectMetadataProfile.snap | 48 ++++ ...dit_author_step_4_EditAuthorPathInput.snap | 48 ++++ ...dit_author_step_5_EditAuthorTagsInput.snap | 48 ++++ ...author_step_6_EditAuthorConfirmPrompt.snap | 48 ++++ ...author_ui_renders_over_author_details.snap | 52 ++++ ..._author_ui_renders_unmonitored_author.snap | 48 ++++ ...y_ui_renders_edit_author_over_library.snap | 48 ++++ 19 files changed, 1284 insertions(+), 2 deletions(-) create mode 100644 src/ui/readarr_ui/library/edit_author_ui.rs create mode 100644 src/ui/readarr_ui/library/edit_author_ui_tests.rs create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorPathInput.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMetadataProfile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMonitorNewItems.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectQualityProfile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorTagsInput.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_0_EditAuthorToggleMonitored.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_1_EditAuthorSelectMonitorNewItems.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_2_EditAuthorSelectQualityProfile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_3_EditAuthorSelectMetadataProfile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_4_EditAuthorPathInput.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_5_EditAuthorTagsInput.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_6_EditAuthorConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_unmonitored_author.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap diff --git a/src/ui/readarr_ui/library/edit_author_ui.rs b/src/ui/readarr_ui/library/edit_author_ui.rs new file mode 100644 index 00000000..b26c0031 --- /dev/null +++ b/src/ui/readarr_ui/library/edit_author_ui.rs @@ -0,0 +1,270 @@ +use std::sync::atomic::Ordering; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::prelude::Layout; +use ratatui::widgets::ListItem; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::modals::EditAuthorModal; +use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS, +}; +use crate::render_selectable_input_box; + +use crate::ui::readarr_ui::library::author_details_ui::AuthorDetailsUi; +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::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "edit_author_ui_tests.rs"] +mod edit_author_ui_tests; + +pub(super) struct EditAuthorUi; + +impl DrawUi for EditAuthorUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + EDIT_AUTHOR_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Route::Readarr(active_readarr_block, context_option) = app.get_current_route() { + if let Some(context) = context_option + && AUTHOR_DETAILS_BLOCKS.contains(&context) + { + draw_popup(f, app, AuthorDetailsUi::draw, Size::Large); + } + + let draw_edit_author_prompt = |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| { + draw_edit_author_confirmation_prompt(f, app, prompt_area); + + match active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => { + draw_edit_author_select_monitor_new_items_popup(f, app); + } + ActiveReadarrBlock::EditAuthorSelectQualityProfile => { + draw_edit_author_select_quality_profile_popup(f, app); + } + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => { + draw_edit_author_select_metadata_profile_popup(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_edit_author_prompt, Size::Long); + } + } +} + +fn build_edit_author_prompt_title(author_name: &str, disambiguation: &str) -> String { + if disambiguation.is_empty() { + format!("Edit - {author_name}") + } else { + format!("Edit - {author_name} ({disambiguation})") + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct EditAuthorPromptHighlights { + monitored: bool, + monitor_new_items: bool, + quality_profile: bool, + metadata_profile: bool, + path: bool, + tags: bool, + confirm: bool, +} + +fn edit_author_prompt_highlights(selected_block: ActiveReadarrBlock) -> EditAuthorPromptHighlights { + EditAuthorPromptHighlights { + monitored: selected_block == ActiveReadarrBlock::EditAuthorToggleMonitored, + monitor_new_items: selected_block == ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + quality_profile: selected_block == ActiveReadarrBlock::EditAuthorSelectQualityProfile, + metadata_profile: selected_block == ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + path: selected_block == ActiveReadarrBlock::EditAuthorPathInput, + tags: selected_block == ActiveReadarrBlock::EditAuthorTagsInput, + confirm: selected_block == ActiveReadarrBlock::EditAuthorConfirmPrompt, + } +} + +fn draw_edit_author_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let author_name = app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text + .clone(); + let author_disambiguation = app + .data + .readarr_data + .authors + .current_selection() + .disambiguation + .clone() + .unwrap_or_default(); + let title = build_edit_author_prompt_title(&author_name, &author_disambiguation); + f.render_widget(title_block_centered(&title), area); + + let yes_no_value = app.data.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = edit_author_prompt_highlights(selected_block); + let EditAuthorModal { + monitor_list, + quality_profile_list, + metadata_profile_list, + monitored, + path, + tags, + } = app + .data + .readarr_data + .edit_author_modal + .as_ref() + .expect("edit_author_modal must exist in this context"); + let selected_monitor_new_items = monitor_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_metadata_profile = metadata_profile_list.current_selection(); + + let [ + _, + monitored_area, + monitor_new_items_area, + quality_profile_area, + metadata_profile_area, + path_area, + tags_area, + _, + buttons_area, + ] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let monitored_checkbox = Checkbox::new("Monitored") + .checked(monitored.unwrap_or_default()) + .highlighted(highlights.monitored); + let monitor_new_items_drop_down_button = Button::default() + .title(selected_monitor_new_items.to_display_str()) + .label("Monitor New Books") + .icon("▼") + .selected(highlights.monitor_new_items); + let quality_profile_drop_down_button = Button::default() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(highlights.quality_profile); + let metadata_profile_drop_down_button = Button::default() + .title(selected_metadata_profile) + .label("Metadata Profile") + .icon("▼") + .selected(highlights.metadata_profile); + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let path_input_box = InputBox::new(&path.text) + .offset(path.offset.load(Ordering::SeqCst)) + .label("Path") + .highlighted(highlights.path) + .selected(active_readarr_block == ActiveReadarrBlock::EditAuthorPathInput); + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::EditAuthorTagsInput); + + match active_readarr_block { + ActiveReadarrBlock::EditAuthorPathInput => path_input_box.show_cursor(f, path_area), + ActiveReadarrBlock::EditAuthorTagsInput => 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::default() + .title("Save") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + f.render_widget(monitored_checkbox, monitored_area); + f.render_widget(monitor_new_items_drop_down_button, monitor_new_items_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(metadata_profile_drop_down_button, metadata_profile_area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); +} + +fn draw_edit_author_select_monitor_new_items_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( + &mut app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context") + .monitor_list, + |monitor_type| ListItem::new(monitor_type.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_edit_author_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context") + .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_author_select_metadata_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let metadata_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context") + .metadata_profile_list, + |metadata_profile| ListItem::new(metadata_profile.clone()), + ); + let popup = Popup::new(metadata_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} diff --git a/src/ui/readarr_ui/library/edit_author_ui_tests.rs b/src/ui/readarr_ui/library/edit_author_ui_tests.rs new file mode 100644 index 00000000..7343d9b2 --- /dev/null +++ b/src/ui/readarr_ui/library/edit_author_ui_tests.rs @@ -0,0 +1,266 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::edit_author_ui::{ + EditAuthorPromptHighlights, EditAuthorUi, build_edit_author_prompt_title, + edit_author_prompt_highlights, + }; + + #[test] + fn test_edit_author_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDIT_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(EditAuthorUi::accepts(active_readarr_block.into())); + } else { + assert!(!EditAuthorUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_build_edit_author_prompt_title_without_disambiguation() { + let title = build_edit_author_prompt_title("Test Author", ""); + + assert_str_eq!(title, "Edit - Test Author"); + } + + #[test] + fn test_build_edit_author_prompt_title_with_disambiguation() { + let title = build_edit_author_prompt_title("Test Author", "American novelist"); + + assert_str_eq!(title, "Edit - Test Author (American novelist)"); + } + + #[rstest] + #[case( + ActiveReadarrBlock::EditAuthorToggleMonitored, + EditAuthorPromptHighlights { + monitored: true, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + path: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: true, + quality_profile: false, + metadata_profile: false, + path: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: true, + metadata_profile: false, + path: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: true, + path: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorPathInput, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + path: true, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorTagsInput, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + path: false, + tags: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorConfirmPrompt, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + path: false, + tags: false, + confirm: true, + } + )] + fn test_edit_author_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: EditAuthorPromptHighlights, + ) { + let highlights = edit_author_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_edit_author_prompt_highlights_covers_every_selection_step() { + for step in EDIT_AUTHOR_SELECTION_BLOCKS { + let highlights = edit_author_prompt_highlights(step[0]); + + assert_ne!( + highlights, + edit_author_prompt_highlights(ActiveReadarrBlock::Authors) + ); + } + } + + mod snapshot_tests { + use rstest::rstest; + + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_profile_lists(app: &mut App<'_>) { + let edit_author_modal = app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context"); + edit_author_modal + .quality_profile_list + .set_items(vec!["eBook".to_owned()]); + edit_author_modal + .metadata_profile_list + .set_items(vec!["Author Only".to_owned()]); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::EditAuthorToggleMonitored)] + #[case(1, ActiveReadarrBlock::EditAuthorSelectMonitorNewItems)] + #[case(2, ActiveReadarrBlock::EditAuthorSelectQualityProfile)] + #[case(3, ActiveReadarrBlock::EditAuthorSelectMetadataProfile)] + #[case(4, ActiveReadarrBlock::EditAuthorPathInput)] + #[case(5, ActiveReadarrBlock::EditAuthorTagsInput)] + #[case(6, ActiveReadarrBlock::EditAuthorConfirmPrompt)] + fn test_edit_author_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("edit_author_step_{step}_{selected_readarr_block}"), + output + ); + } + + #[rstest] + #[case(1, ActiveReadarrBlock::EditAuthorSelectMonitorNewItems)] + #[case(2, ActiveReadarrBlock::EditAuthorSelectQualityProfile)] + #[case(3, ActiveReadarrBlock::EditAuthorSelectMetadataProfile)] + #[case(4, ActiveReadarrBlock::EditAuthorPathInput)] + #[case(5, ActiveReadarrBlock::EditAuthorTagsInput)] + fn test_edit_author_ui_renders_active_block( + #[case] step: usize, + #[case] active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("edit_author_active_{active_readarr_block}"), output); + } + + #[test] + fn test_edit_author_ui_renders_unmonitored_author() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context") + .monitored = Some(false); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edit_author_ui_renders_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .into(), + ); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs index 6e21d9ba..8432d7a0 100644 --- a/src/ui/readarr_ui/library/library_ui_tests.rs +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -5,7 +5,7 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, - EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, + EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; @@ -20,6 +20,7 @@ mod tests { blocks.extend(BOOK_DETAILS_BLOCKS); blocks.extend(EDITION_DETAILS_BLOCKS); blocks.extend(ADD_AUTHOR_BLOCKS); + blocks.extend(EDIT_AUTHOR_BLOCKS); for active_readarr_block in ActiveReadarrBlock::iter() { if blocks.contains(&active_readarr_block) { @@ -187,7 +188,10 @@ mod tests { mod snapshot_tests { use crate::app::App; - use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_AUTHOR_SELECTION_BLOCKS, + }; use rstest::rstest; use crate::ui::DrawUi; @@ -279,5 +283,19 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_library_ui_renders_edit_author_over_library() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.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/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index 08b0e235..89a7e6e2 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -1,5 +1,6 @@ use add_author_ui::AddAuthorUi; use author_details_ui::AuthorDetailsUi; +use edit_author_ui::EditAuthorUi; use ratatui::{ Frame, layout::{Constraint, Rect}, @@ -29,6 +30,7 @@ use crate::{ mod add_author_ui; mod author_details_ui; mod book_details_ui; +mod edit_author_ui; mod edition_details_ui; #[cfg(test)] @@ -41,6 +43,7 @@ impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Readarr(active_readarr_block, _) = route { return AddAuthorUi::accepts(route) + || EditAuthorUi::accepts(route) || AuthorDetailsUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_readarr_block); } @@ -55,6 +58,7 @@ impl DrawUi for LibraryUi { match route { _ if AddAuthorUi::accepts(route) => AddAuthorUi::draw(f, app, area), _ if AuthorDetailsUi::accepts(route) => AuthorDetailsUi::draw(f, app, area), + _ if EditAuthorUi::accepts(route) => EditAuthorUi::draw(f, app, area), Route::Readarr(ActiveReadarrBlock::UpdateAllAuthorsPrompt, _) => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Authors") diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorPathInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorPathInput.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMetadataProfile.snap new file mode 100644 index 00000000..769004c7 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───────────────────────────────╮──────────────────────────────╮ │ + │ Monito│Author Only │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ ╰───────────────────────────────╯──────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMonitorNewItems.snap new file mode 100644 index 00000000..aebf8f87 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───────────────────────────────╮──────────────────────────────╮ │ + │ Monito│All Books │ ▼ │ │ + │ │No New Books │──────────────────────────────╯ │ + │ │New Books │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ ╰───────────────────────────────╯──────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectQualityProfile.snap new file mode 100644 index 00000000..c09fd7f5 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───────────────────────────────╮──────────────────────────────╮ │ + │ Monito│eBook │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ ╰───────────────────────────────╯──────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorTagsInput.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_0_EditAuthorToggleMonitored.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_0_EditAuthorToggleMonitored.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_0_EditAuthorToggleMonitored.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_1_EditAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_1_EditAuthorSelectMonitorNewItems.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_1_EditAuthorSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_2_EditAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_2_EditAuthorSelectQualityProfile.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_2_EditAuthorSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_3_EditAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_3_EditAuthorSelectMetadataProfile.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_3_EditAuthorSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_4_EditAuthorPathInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_4_EditAuthorPathInput.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_4_EditAuthorPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_5_EditAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_5_EditAuthorTagsInput.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_5_EditAuthorTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_6_EditAuthorConfirmPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_6_EditAuthorConfirmPrompt.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_6_EditAuthorConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap new file mode 100644 index 00000000..6752d58a --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ │ + │Genres: science fic│ │ │ + │Rating: N/A │ │ │ + │Path: /nfs/books/Te│ │ │ + │Quality Profile: St│ │ │ + │Metadata Profile: S│ │ │ + │Monitored: Yes │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╭───╮ │ │ + │╭ Author Details │ Monitored: │ ✔ │ │────────────────────╮│ + ││ Books │ History ││ ╰───╯ │ ││ + ││──────────────────│ ╭─────────────────────────────────────────────────╮ │────────────────────││ + ││ Monitored │ Monitor New Books: │All Books ▼ │ │ ││ + ││=> 🏷 │ ╰─────────────────────────────────────────────────╯ │B ││ + ││ │ ╭─────────────────────────────────────────────────╮ │ ││ + ││ │ Quality Profile: │eBook ▼ │ │ ││ + ││ │ ╰─────────────────────────────────────────────────╯ │ ││ + ││ │ ╭─────────────────────────────────────────────────╮ │ ││ + ││ │ Metadata Profile: │Author Only ▼ │ │ ││ + ││ │ ╰─────────────────────────────────────────────────╯ │ ││ + ││ │ ╭─────────────────────────────────────────────────╮ │ ││ + ││ │ Path: │/nfs/books │ │ ││ + ││ │ ╰─────────────────────────────────────────────────╯ │ ││ + ││ │ ╭─────────────────────────────────────────────────╮ │ ││ + ││ │ Tags: │alex │ │ ││ + ││ │ ╰─────────────────────────────────────────────────╯ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ ││ + ││ ││ Save ││ Cancel ││ ││ + ││ │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ ││ + ││ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_unmonitored_author.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_unmonitored_author.snap new file mode 100644 index 00000000..09a3883e --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_unmonitored_author.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap new file mode 100644 index 00000000..47731f7c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │Standard ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Standard ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ From 2b07ad96f1a23f06721603fbc61132a7b3bb53cb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 21:51:21 -0600 Subject: [PATCH 085/170] feat(readarr): add the delete author UI --- src/ui/readarr_ui/library/delete_author_ui.rs | 85 ++++++++++ .../library/delete_author_ui_tests.rs | 155 ++++++++++++++++++ src/ui/readarr_ui/library/library_ui_tests.rs | 22 ++- src/ui/readarr_ui/library/mod.rs | 4 + ...r_step_0_DeleteAuthorToggleDeleteFile.snap | 38 +++++ ..._1_DeleteAuthorToggleAddListExclusion.snap | 38 +++++ ...thor_step_2_DeleteAuthorConfirmPrompt.snap | 38 +++++ ...i_renders_inverted_delete_preferences.snap | 38 +++++ ...ui_renders_delete_author_over_library.snap | 38 +++++ 9 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 src/ui/readarr_ui/library/delete_author_ui.rs create mode 100644 src/ui/readarr_ui/library/delete_author_ui_tests.rs create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_0_DeleteAuthorToggleDeleteFile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_1_DeleteAuthorToggleAddListExclusion.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_2_DeleteAuthorConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_ui_renders_inverted_delete_preferences.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap diff --git a/src/ui/readarr_ui/library/delete_author_ui.rs b/src/ui/readarr_ui/library/delete_author_ui.rs new file mode 100644 index 00000000..791ce49d --- /dev/null +++ b/src/ui/readarr_ui/library/delete_author_ui.rs @@ -0,0 +1,85 @@ +use ratatui::Frame; +use ratatui::layout::Rect; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_BLOCKS, +}; +use crate::ui::DrawUi; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::popup::{Popup, Size}; + +#[cfg(test)] +#[path = "delete_author_ui_tests.rs"] +mod delete_author_ui_tests; + +pub(super) struct DeleteAuthorUi; + +impl DrawUi for DeleteAuthorUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + DELETE_AUTHOR_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::DeleteAuthorPrompt, _) + ) { + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = delete_author_prompt_highlights(selected_block); + let prompt = format!( + "Do you really want to delete the author: \n{}?", + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text + ); + let checkboxes = vec![ + Checkbox::new("Delete Author Files") + .checked(app.data.readarr_data.delete_files) + .highlighted(highlights.delete_files), + Checkbox::new("Add List Exclusion") + .checked(app.data.readarr_data.add_import_list_exclusion) + .highlighted(highlights.add_import_list_exclusion), + ]; + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Author") + .prompt(&prompt) + .checkboxes(checkboxes) + .yes_no_highlighted(highlights.confirm) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct DeleteAuthorPromptHighlights { + delete_files: bool, + add_import_list_exclusion: bool, + confirm: bool, +} + +fn delete_author_prompt_highlights( + selected_block: ActiveReadarrBlock, +) -> DeleteAuthorPromptHighlights { + DeleteAuthorPromptHighlights { + delete_files: selected_block == ActiveReadarrBlock::DeleteAuthorToggleDeleteFile, + add_import_list_exclusion: selected_block + == ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion, + confirm: selected_block == ActiveReadarrBlock::DeleteAuthorConfirmPrompt, + } +} diff --git a/src/ui/readarr_ui/library/delete_author_ui_tests.rs b/src/ui/readarr_ui/library/delete_author_ui_tests.rs new file mode 100644 index 00000000..dbcb682e --- /dev/null +++ b/src/ui/readarr_ui/library/delete_author_ui_tests.rs @@ -0,0 +1,155 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_BLOCKS, DELETE_AUTHOR_SELECTION_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::delete_author_ui::{ + DeleteAuthorPromptHighlights, DeleteAuthorUi, delete_author_prompt_highlights, + }; + + #[test] + fn test_delete_author_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DELETE_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(DeleteAuthorUi::accepts(active_readarr_block.into())); + } else { + assert!(!DeleteAuthorUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile, + DeleteAuthorPromptHighlights { + delete_files: true, + add_import_list_exclusion: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion, + DeleteAuthorPromptHighlights { + delete_files: false, + add_import_list_exclusion: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::DeleteAuthorConfirmPrompt, + DeleteAuthorPromptHighlights { + delete_files: false, + add_import_list_exclusion: false, + confirm: true, + } + )] + #[case( + ActiveReadarrBlock::Authors, + DeleteAuthorPromptHighlights { + delete_files: false, + add_import_list_exclusion: false, + confirm: false, + } + )] + fn test_delete_author_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: DeleteAuthorPromptHighlights, + ) { + let highlights = delete_author_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_delete_author_prompt_highlights_covers_every_selection_step() { + for step in DELETE_AUTHOR_SELECTION_BLOCKS { + let highlights = delete_author_prompt_highlights(step[0]); + + assert_ne!( + highlights, + delete_author_prompt_highlights(ActiveReadarrBlock::Authors) + ); + } + } + + mod snapshot_tests { + use rstest::rstest; + + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_delete_preferences(app: &mut App<'_>) { + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + } + + #[rstest] + #[case(0, ActiveReadarrBlock::DeleteAuthorToggleDeleteFile)] + #[case(1, ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion)] + #[case(2, ActiveReadarrBlock::DeleteAuthorConfirmPrompt)] + fn test_delete_author_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + seed_distinct_delete_preferences(&mut app); + let mut selected_block = BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("delete_author_step_{step}_{selected_readarr_block}"), + output + ); + } + + #[test] + fn test_delete_author_ui_renders_inverted_delete_preferences() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.delete_files = false; + app.data.readarr_data.add_import_list_exclusion = true; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + #[case(ActiveReadarrBlock::DeleteAuthorConfirmPrompt)] + #[case(ActiveReadarrBlock::DeleteAuthorToggleDeleteFile)] + #[case(ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion)] + fn test_delete_author_ui_renders_nothing_when_route_is_not_the_prompt( + #[case] active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_delete_preferences(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteAuthorUi::draw(f, app, f.area()); + }); + + assert!(!output.contains("Delete Author")); + assert!(!output.contains("Add List Exclusion")); + } + } +} diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs index 8432d7a0..2e56081a 100644 --- a/src/ui/readarr_ui/library/library_ui_tests.rs +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -5,7 +5,7 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, - EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, + DELETE_AUTHOR_BLOCKS, EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; @@ -20,6 +20,7 @@ mod tests { blocks.extend(BOOK_DETAILS_BLOCKS); blocks.extend(EDITION_DETAILS_BLOCKS); blocks.extend(ADD_AUTHOR_BLOCKS); + blocks.extend(DELETE_AUTHOR_BLOCKS); blocks.extend(EDIT_AUTHOR_BLOCKS); for active_readarr_block in ActiveReadarrBlock::iter() { @@ -190,7 +191,7 @@ mod tests { use crate::app::App; use crate::models::BlockSelectionState; use crate::models::servarr_data::readarr::readarr_data::{ - ActiveReadarrBlock, EDIT_AUTHOR_SELECTION_BLOCKS, + ActiveReadarrBlock, DELETE_AUTHOR_SELECTION_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, }; use rstest::rstest; @@ -297,5 +298,22 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_library_ui_renders_delete_author_over_library() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.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/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index 89a7e6e2..c1883812 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -1,5 +1,6 @@ use add_author_ui::AddAuthorUi; use author_details_ui::AuthorDetailsUi; +use delete_author_ui::DeleteAuthorUi; use edit_author_ui::EditAuthorUi; use ratatui::{ Frame, @@ -30,6 +31,7 @@ use crate::{ mod add_author_ui; mod author_details_ui; mod book_details_ui; +mod delete_author_ui; mod edit_author_ui; mod edition_details_ui; @@ -43,6 +45,7 @@ impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Readarr(active_readarr_block, _) = route { return AddAuthorUi::accepts(route) + || DeleteAuthorUi::accepts(route) || EditAuthorUi::accepts(route) || AuthorDetailsUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_readarr_block); @@ -57,6 +60,7 @@ impl DrawUi for LibraryUi { match route { _ if AddAuthorUi::accepts(route) => AddAuthorUi::draw(f, app, area), + _ if DeleteAuthorUi::accepts(route) => DeleteAuthorUi::draw(f, app, area), _ if AuthorDetailsUi::accepts(route) => AuthorDetailsUi::draw(f, app, area), _ if EditAuthorUi::accepts(route) => EditAuthorUi::draw(f, app, area), Route::Readarr(ActiveReadarrBlock::UpdateAllAuthorsPrompt, _) => { diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_0_DeleteAuthorToggleDeleteFile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_0_DeleteAuthorToggleDeleteFile.snap new file mode 100644 index 00000000..7847edec --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_0_DeleteAuthorToggleDeleteFile.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_author_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_1_DeleteAuthorToggleAddListExclusion.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_1_DeleteAuthorToggleAddListExclusion.snap new file mode 100644 index 00000000..7847edec --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_1_DeleteAuthorToggleAddListExclusion.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_author_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_2_DeleteAuthorConfirmPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_2_DeleteAuthorConfirmPrompt.snap new file mode 100644 index 00000000..7847edec --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_2_DeleteAuthorConfirmPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_author_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_ui_renders_inverted_delete_preferences.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_ui_renders_inverted_delete_preferences.snap new file mode 100644 index 00000000..028d0ebe --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_ui_renders_inverted_delete_preferences.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_author_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ ✔ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap new file mode 100644 index 00000000..31bd8c98 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ From 773719dbd22f4fa3b5b14a19a8afe801ea801bf4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 21:58:35 -0600 Subject: [PATCH 086/170] feat(readarr): add the delete book UI --- .../readarr_ui/library/author_details_ui.rs | 6 +- .../library/author_details_ui_tests.rs | 25 ++- src/ui/readarr_ui/library/delete_book_ui.rs | 75 +++++++++ .../library/delete_book_ui_tests.rs | 153 ++++++++++++++++++ src/ui/readarr_ui/library/library_ui_tests.rs | 4 +- src/ui/readarr_ui/library/mod.rs | 1 + ...nders_delete_book_over_author_details.snap | 52 ++++++ ...ook_step_0_DeleteBookToggleDeleteFile.snap | 38 +++++ ...ep_1_DeleteBookToggleAddListExclusion.snap | 38 +++++ ...e_book_step_2_DeleteBookConfirmPrompt.snap | 38 +++++ ...i_renders_inverted_delete_preferences.snap | 38 +++++ 11 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 src/ui/readarr_ui/library/delete_book_ui.rs create mode 100644 src/ui/readarr_ui/library/delete_book_ui_tests.rs create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_0_DeleteBookToggleDeleteFile.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_1_DeleteBookToggleAddListExclusion.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_2_DeleteBookConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_ui_renders_inverted_delete_preferences.snap diff --git a/src/ui/readarr_ui/library/author_details_ui.rs b/src/ui/readarr_ui/library/author_details_ui.rs index 7817b7fb..3a231887 100644 --- a/src/ui/readarr_ui/library/author_details_ui.rs +++ b/src/ui/readarr_ui/library/author_details_ui.rs @@ -14,6 +14,7 @@ use crate::models::servarr_data::readarr::readarr_data::{ AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, }; use crate::ui::readarr_ui::library::book_details_ui::BookDetailsUi; +use crate::ui::readarr_ui::library::delete_book_ui::DeleteBookUi; use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::decorate_peer_style; @@ -42,7 +43,9 @@ impl DrawUi for AuthorDetailsUi { let Route::Readarr(active_readarr_block, _) = route else { return false; }; - BookDetailsUi::accepts(route) || AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) + BookDetailsUi::accepts(route) + || DeleteBookUi::accepts(route) + || AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { @@ -75,6 +78,7 @@ impl DrawUi for AuthorDetailsUi { draw_author_details(f, app, content_area); match active_readarr_block { + _ if DeleteBookUi::accepts(route) => DeleteBookUi::draw(f, app, _area), ActiveReadarrBlock::AuthorHistoryDetails => { draw_author_history_item_details_popup(f, app); } diff --git a/src/ui/readarr_ui/library/author_details_ui_tests.rs b/src/ui/readarr_ui/library/author_details_ui_tests.rs index 2358f45b..332dc656 100644 --- a/src/ui/readarr_ui/library/author_details_ui_tests.rs +++ b/src/ui/readarr_ui/library/author_details_ui_tests.rs @@ -7,7 +7,8 @@ mod tests { use crate::models::readarr_models::{Book, BookStatistics}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, DELETE_BOOK_BLOCKS, + EDITION_DETAILS_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::author_details_ui::{ @@ -20,6 +21,7 @@ mod tests { let mut author_details_blocks = AUTHOR_DETAILS_BLOCKS.to_vec(); author_details_blocks.extend(BOOK_DETAILS_BLOCKS); author_details_blocks.extend(EDITION_DETAILS_BLOCKS); + author_details_blocks.extend(DELETE_BOOK_BLOCKS); ActiveReadarrBlock::iter().for_each(|active_readarr_block| { if author_details_blocks.contains(&active_readarr_block) { @@ -150,8 +152,11 @@ mod tests { use rstest::rstest; use crate::app::App; + use crate::models::BlockSelectionState; use crate::models::readarr_models::{AuthorStatistics, BookStatistics}; - use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_BOOK_SELECTION_BLOCKS, + }; use crate::models::stateful_table::StatefulTable; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::author_details_ui::AuthorDetailsUi; @@ -311,5 +316,21 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_author_details_ui_renders_delete_book_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/readarr_ui/library/delete_book_ui.rs b/src/ui/readarr_ui/library/delete_book_ui.rs new file mode 100644 index 00000000..799e1b57 --- /dev/null +++ b/src/ui/readarr_ui/library/delete_book_ui.rs @@ -0,0 +1,75 @@ +use ratatui::Frame; +use ratatui::layout::Rect; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DELETE_BOOK_BLOCKS}; +use crate::ui::DrawUi; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::popup::{Popup, Size}; + +#[cfg(test)] +#[path = "delete_book_ui_tests.rs"] +mod delete_book_ui_tests; + +pub(super) struct DeleteBookUi; + +impl DrawUi for DeleteBookUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + DELETE_BOOK_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::DeleteBookPrompt, _) + ) { + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = delete_book_prompt_highlights(selected_block); + let prompt = format!( + "Do you really want to delete the book: \n{}?", + app.data.readarr_data.books.current_selection().title.text + ); + let checkboxes = vec![ + Checkbox::new("Delete Book Files") + .checked(app.data.readarr_data.delete_files) + .highlighted(highlights.delete_files), + Checkbox::new("Add List Exclusion") + .checked(app.data.readarr_data.add_import_list_exclusion) + .highlighted(highlights.add_import_list_exclusion), + ]; + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Book") + .prompt(&prompt) + .checkboxes(checkboxes) + .yes_no_highlighted(highlights.confirm) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct DeleteBookPromptHighlights { + delete_files: bool, + add_import_list_exclusion: bool, + confirm: bool, +} + +fn delete_book_prompt_highlights(selected_block: ActiveReadarrBlock) -> DeleteBookPromptHighlights { + DeleteBookPromptHighlights { + delete_files: selected_block == ActiveReadarrBlock::DeleteBookToggleDeleteFile, + add_import_list_exclusion: selected_block + == ActiveReadarrBlock::DeleteBookToggleAddListExclusion, + confirm: selected_block == ActiveReadarrBlock::DeleteBookConfirmPrompt, + } +} diff --git a/src/ui/readarr_ui/library/delete_book_ui_tests.rs b/src/ui/readarr_ui/library/delete_book_ui_tests.rs new file mode 100644 index 00000000..a1dea0f1 --- /dev/null +++ b/src/ui/readarr_ui/library/delete_book_ui_tests.rs @@ -0,0 +1,153 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_BOOK_BLOCKS, DELETE_BOOK_SELECTION_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::delete_book_ui::{ + DeleteBookPromptHighlights, DeleteBookUi, delete_book_prompt_highlights, + }; + + #[test] + fn test_delete_book_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DELETE_BOOK_BLOCKS.contains(&active_readarr_block) { + assert!(DeleteBookUi::accepts(active_readarr_block.into())); + } else { + assert!(!DeleteBookUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::DeleteBookToggleDeleteFile, + DeleteBookPromptHighlights { + delete_files: true, + add_import_list_exclusion: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::DeleteBookToggleAddListExclusion, + DeleteBookPromptHighlights { + delete_files: false, + add_import_list_exclusion: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::DeleteBookConfirmPrompt, + DeleteBookPromptHighlights { + delete_files: false, + add_import_list_exclusion: false, + confirm: true, + } + )] + #[case( + ActiveReadarrBlock::BookDetails, + DeleteBookPromptHighlights { + delete_files: false, + add_import_list_exclusion: false, + confirm: false, + } + )] + fn test_delete_book_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: DeleteBookPromptHighlights, + ) { + let highlights = delete_book_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_delete_book_prompt_highlights_covers_every_selection_step() { + for step in DELETE_BOOK_SELECTION_BLOCKS { + let highlights = delete_book_prompt_highlights(step[0]); + + assert_ne!( + highlights, + delete_book_prompt_highlights(ActiveReadarrBlock::BookDetails) + ); + } + } + + mod snapshot_tests { + use rstest::rstest; + + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_delete_preferences(app: &mut App<'_>) { + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + } + + #[rstest] + #[case(0, ActiveReadarrBlock::DeleteBookToggleDeleteFile)] + #[case(1, ActiveReadarrBlock::DeleteBookToggleAddListExclusion)] + #[case(2, ActiveReadarrBlock::DeleteBookConfirmPrompt)] + fn test_delete_book_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + seed_distinct_delete_preferences(&mut app); + let mut selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteBookUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("delete_book_step_{step}_{selected_readarr_block}"), + output + ); + } + + #[test] + fn test_delete_book_ui_renders_inverted_delete_preferences() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.delete_files = false; + app.data.readarr_data.add_import_list_exclusion = true; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteBookUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + #[case(ActiveReadarrBlock::DeleteBookConfirmPrompt)] + #[case(ActiveReadarrBlock::DeleteBookToggleDeleteFile)] + #[case(ActiveReadarrBlock::DeleteBookToggleAddListExclusion)] + fn test_delete_book_ui_renders_nothing_when_route_is_not_the_prompt( + #[case] active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_delete_preferences(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteBookUi::draw(f, app, f.area()); + }); + + assert!(!output.contains("Delete Book")); + assert!(!output.contains("Add List Exclusion")); + } + } +} diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs index 2e56081a..1d0b4406 100644 --- a/src/ui/readarr_ui/library/library_ui_tests.rs +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -5,7 +5,8 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, - DELETE_AUTHOR_BLOCKS, EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, + DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, + LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; @@ -21,6 +22,7 @@ mod tests { blocks.extend(EDITION_DETAILS_BLOCKS); blocks.extend(ADD_AUTHOR_BLOCKS); blocks.extend(DELETE_AUTHOR_BLOCKS); + blocks.extend(DELETE_BOOK_BLOCKS); blocks.extend(EDIT_AUTHOR_BLOCKS); for active_readarr_block in ActiveReadarrBlock::iter() { diff --git a/src/ui/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index c1883812..2467a643 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -32,6 +32,7 @@ mod add_author_ui; mod author_details_ui; mod book_details_ui; mod delete_author_ui; +mod delete_book_ui; mod edit_author_ui; mod edition_details_ui; diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap new file mode 100644 index 00000000..4a83f5a9 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Overview: some interesting description of the author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │ │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭────────────────────── Delete Book ──────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you really want to delete the book: │ ││ + ││─────────────────────────────────────────│ Test Book? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││ │ ╭───╮ │ ││ + ││ │ Delete Book Files: │ ✔ │ │ ││ + ││ │ ╰───╯ │ ││ + ││ │ ╭───╮ │ ││ + ││ │ Add List Exclusion: │ │ │ ││ + ││ │ ╰───╯ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_0_DeleteBookToggleDeleteFile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_0_DeleteBookToggleDeleteFile.snap new file mode 100644 index 00000000..2b7fcb09 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_0_DeleteBookToggleDeleteFile.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_book_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭────────────────────── Delete Book ──────────────────────╮ + │ Do you really want to delete the book: │ + │ Test Book? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Book Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_1_DeleteBookToggleAddListExclusion.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_1_DeleteBookToggleAddListExclusion.snap new file mode 100644 index 00000000..2b7fcb09 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_1_DeleteBookToggleAddListExclusion.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_book_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭────────────────────── Delete Book ──────────────────────╮ + │ Do you really want to delete the book: │ + │ Test Book? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Book Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_2_DeleteBookConfirmPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_2_DeleteBookConfirmPrompt.snap new file mode 100644 index 00000000..2b7fcb09 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_2_DeleteBookConfirmPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_book_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭────────────────────── Delete Book ──────────────────────╮ + │ Do you really want to delete the book: │ + │ Test Book? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Book Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_ui_renders_inverted_delete_preferences.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_ui_renders_inverted_delete_preferences.snap new file mode 100644 index 00000000..d472cc86 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_ui_renders_inverted_delete_preferences.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_book_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭────────────────────── Delete Book ──────────────────────╮ + │ Do you really want to delete the book: │ + │ Test Book? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Book Files: │ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ ✔ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ From e9d3851db89a218ce02c03f3a5a2525902bb090a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 22:05:11 -0600 Subject: [PATCH 087/170] fix(readarr): stop a long author overview hiding the other fields The overview was rendered second of twelve into a fixed height block with no scrolling, so free text of any length pushed status, genres, rating, path and the statistics off the bottom where they could not be reached. Every author in a real library has an overview and the median runs several lines, so those fields were usually invisible. Rendering it last keeps the structured fields on screen and lets the overview take whatever space is left. --- .../readarr_ui/library/author_details_ui.rs | 9 +++++---- .../library/author_details_ui_tests.rs | 20 +++++++++++++++++++ ...tests__author_details_AuthorDetails_0.snap | 2 +- ...author_details_AuthorHistoryDetails_1.snap | 2 +- ...hor_details_AuthorHistorySortPrompt_1.snap | 2 +- ...tests__author_details_AuthorHistory_1.snap | 2 +- ...ils_AutomaticallySearchAuthorPrompt_0.snap | 2 +- ...ils_AutomaticallySearchAuthorPrompt_1.snap | 2 +- ...ils_AutomaticallySearchAuthorPrompt_2.snap | 2 +- ...or_details_FilterAuthorHistoryError_1.snap | 2 +- ..._author_details_FilterAuthorHistory_1.snap | 2 +- ...ils_ManualAuthorSearchConfirmPrompt_2.snap | 2 +- ...etails_ManualAuthorSearchSortPrompt_2.snap | 2 +- ...__author_details_ManualAuthorSearch_2.snap | 2 +- ...or_details_SearchAuthorHistoryError_1.snap | 2 +- ..._author_details_SearchAuthorHistory_1.snap | 2 +- ...ts__author_details_SearchBooksError_0.snap | 2 +- ...t_tests__author_details_SearchBooks_0.snap | 2 +- ...r_details_UpdateAndScanAuthorPrompt_0.snap | 2 +- ...r_details_UpdateAndScanAuthorPrompt_1.snap | 2 +- ...r_details_UpdateAndScanAuthorPrompt_2.snap | 2 +- ...tic_search_prompt_over_author_details.snap | 2 +- ...ders_book_details_over_author_details.snap | 10 +++++----- ...nders_delete_book_over_author_details.snap | 2 +- ..._author_details_ui_renders_statistics.snap | 2 +- ...e_and_scan_prompt_over_author_details.snap | 2 +- ...s__empty_author_details_AuthorDetails.snap | 2 +- ...s__empty_author_details_AuthorHistory.snap | 2 +- ...y_author_details_AuthorHistoryDetails.snap | 2 +- ...pty_author_details_ManualAuthorSearch.snap | 2 +- ..._loading_author_details_AuthorDetails.snap | 2 +- ..._loading_author_details_AuthorHistory.snap | 2 +- ...ing_author_details_ManualAuthorSearch.snap | 2 +- ...author_ui_renders_over_author_details.snap | 6 +++--- ...i_renders_author_details_over_library.snap | 2 +- 35 files changed, 64 insertions(+), 43 deletions(-) diff --git a/src/ui/readarr_ui/library/author_details_ui.rs b/src/ui/readarr_ui/library/author_details_ui.rs index 3a231887..f93fd8aa 100644 --- a/src/ui/readarr_ui/library/author_details_ui.rs +++ b/src/ui/readarr_ui/library/author_details_ui.rs @@ -177,10 +177,6 @@ fn draw_author_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { "Author: ".primary().bold(), current_selection.author_name.text.clone().primary().bold(), ]), - Line::from(vec![ - "Overview: ".primary().bold(), - overview.default_color(), - ]), Line::from(vec![ "Status: ".primary().bold(), current_selection.status.to_display_str().default_color(), @@ -236,6 +232,11 @@ fn draw_author_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]); } + author_description.push(Line::from(vec![ + "Overview: ".primary().bold(), + overview.default_color(), + ])); + let description_paragraph = Paragraph::new(author_description) .block(borderless_block()) .wrap(Wrap { trim: true }); diff --git a/src/ui/readarr_ui/library/author_details_ui_tests.rs b/src/ui/readarr_ui/library/author_details_ui_tests.rs index 332dc656..ae1befa3 100644 --- a/src/ui/readarr_ui/library/author_details_ui_tests.rs +++ b/src/ui/readarr_ui/library/author_details_ui_tests.rs @@ -200,6 +200,26 @@ mod tests { ); } + #[test] + fn test_author_details_ui_renders_every_field_despite_a_long_overview() { + let mut app = App::test_default_fully_populated(); + let mut author = app.data.readarr_data.authors.current_selection().clone(); + author.overview = Some("Carl Edward Sagan was an American astronomer, planetary scientist, cosmologist, astrophysicist, astrobiologist, author, and science communicator. His best known scientific contribution is his research on the possibility of extraterrestrial life, including experimental demonstration of the production of amino acids from basic chemicals by radiation. He assembled the first physical messages sent into space, the Pioneer plaque and the Voyager Golden Record, universal messages that could potentially be understood by any extraterrestrial intelligence that might find them. He argued in favor of the hypothesis, which has become accepted, that the high surface temperatures of Venus are the result of the greenhouse effect.".to_owned()); + app.data.readarr_data.authors.set_items(vec![author]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "Status:"); + assert_contains!(output, "Genres:"); + assert_contains!(output, "Rating:"); + assert_contains!(output, "Path:"); + assert_contains!(output, "Monitored:"); + assert_contains!(output, "Overview:"); + } + #[rstest] #[case(ActiveReadarrBlock::AuthorDetails, 0)] #[case(ActiveReadarrBlock::AuthorHistory, 1)] diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap index 4b6e0d8a..bf512d36 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap index 2f5a2119..00dd5514 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap index 49abd4b8..7e6599c4 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap index 4d105d17..941f5dcc 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap index 6e9a1615..937cd23e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap index a8436012..da4145de 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap index 5c5c8b4d..84092693 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap index 182f53b7..bfd24b55 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap index dea5d6cf..5915ff9f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap index 8b0627a4..f52c05a8 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ ╭───────────────── Download Rejected Release ──────────────────╮ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap index f2a03272..6655add5 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap index 2f8dacfc..2de5af4d 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap index 511ff704..2d943a33 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap index 74c8b861..b81bc8bb 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap index 6d8acb06..cebdff92 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap index 66bac5cf..e3a9fdbb 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap index 0c349acf..8cec0e85 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap index 85ec2084..4315396b 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap index 6cb37307..13882a49 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap index 6e9a1615..937cd23e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap index 7b962065..b681ef93 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap @@ -7,14 +7,14 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Auth╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ - │Over│ Editions │ History │ File │ Manual Search │ │ - │Stat│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ - │Genr│ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ │ - │Rati│=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ │ - │Path│ │ │ + │Stat│ Editions │ History │ File │ Manual Search │ │ + │Genr│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ + │Rati│ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ │ + │Path│=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ │ │Qual│ │ │ │Meta│ │ │ │Moni│ │ │ + │Over│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap index 4a83f5a9..6a98fe7d 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap index ac285c3a..82d57a62 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -18,6 +17,7 @@ expression: output │Books: 3 │ │Files: 2/3 │ │Size on Disk: 3.30 GB │ + │Overview: some interesting description of the author │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap index 0c349acf..8cec0e85 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap index 96083953..5ffdc770 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap index 96083953..5ffdc770 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap index a4cfb206..420df740 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap index 5c0ea0b1..7f1eb28e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap index 5c0ea0b1..7f1eb28e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap index 5c0ea0b1..7f1eb28e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap index 5c0ea0b1..7f1eb28e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap @@ -7,7 +7,6 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap index 6752d58a..63e608f7 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap @@ -7,14 +7,14 @@ expression: output ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ - │Status: Continuing ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ │ - │Genres: science fic│ │ │ + │Status: Continuing │ + │Genres: science fic╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ │ │Rating: N/A │ │ │ │Path: /nfs/books/Te│ │ │ │Quality Profile: St│ │ │ │Metadata Profile: S│ │ │ │Monitored: Yes │ │ │ + │Overview: some inte│ │ │ │ │ │ │ │ │ │ │ │ │ ╭───╮ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap index d19b01b3..cc7cd2a4 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -7,7 +7,6 @@ expression: output => Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ - │Overview: some interesting description of the author │ │Status: Continuing │ │Genres: science fiction │ │Rating: N/A │ @@ -15,6 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ + │Overview: some interesting description of the author │ │ │ │ │ │ │ From bd96c4ab235198739cf610754dcb32113c25aced Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 23:54:49 -0600 Subject: [PATCH 088/170] feat(readarr): add the blocklist handler --- .../blocklist/blocklist_handler_tests.rs | 627 ++++++++++++++++++ .../readarr_handlers/blocklist/mod.rs | 224 +++++++ src/handlers/readarr_handlers/mod.rs | 5 + .../readarr_handlers/readarr_handler_tests.rs | 18 + 4 files changed, 874 insertions(+) create mode 100644 src/handlers/readarr_handlers/blocklist/blocklist_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/blocklist/mod.rs diff --git a/src/handlers/readarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/readarr_handlers/blocklist/blocklist_handler_tests.rs new file mode 100644 index 00000000..e3964cc7 --- /dev/null +++ b/src/handlers/readarr_handlers/blocklist/blocklist_handler_tests.rs @@ -0,0 +1,627 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::blocklist::{BlocklistHandler, blocklist_sorting_options}; + use crate::models::readarr_models::{Author, BlocklistItem}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BLOCKLIST_BLOCKS}; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + 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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteBlocklistItemPrompt.into()); + } + + #[test] + fn test_delete_blocklist_item_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Blocklist.into() + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_blocklist_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(2); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Downloads.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Downloads.into()); + } + + #[rstest] + fn test_blocklist_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(2); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::History.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::History.into()); + } + + #[rstest] + fn test_blocklist_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + BlocklistHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use crate::assert_navigation_popped; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_blocklist_submit() { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BlocklistItemDetails.into()); + } + + #[test] + fn test_blocklist_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Blocklist.into() + ); + } + + #[rstest] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ReadarrEvent::DeleteBlocklistItem(3) + )] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ReadarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm_submit( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + + #[rstest] + fn test_blocklist_prompt_decline_submit( + #[values( + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt + )] + prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::Blocklist.into()); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::DeleteBlocklistItemPrompt + )] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt + )] + fn test_blocklist_prompt_blocks_esc( + #[case] base_block: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(base_block.into()); + app.push_navigation_stack(prompt_block.into()); + app.data.readarr_data.prompt_confirm = true; + + BlocklistHandler::new(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert_navigation_popped!(app, base_block.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_esc_blocklist_item_details() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveReadarrBlock::BlocklistItemDetails.into()); + + BlocklistHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::BlocklistItemDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Blocklist.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + + #[test] + fn test_refresh_blocklist_key() { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::Blocklist.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_blocklist_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Blocklist.into() + ); + assert!(!app.should_refresh); + } + + #[test] + fn test_clear_blocklist_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BlocklistClearAllItemsPrompt.into()); + } + + #[test] + fn test_clear_blocklist_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Blocklist.into() + ); + } + + #[rstest] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ReadarrEvent::DeleteBlocklistItem(3) + )] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ReadarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + } + + #[test] + fn test_blocklist_sorting_options_author_name() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.author + .author_name + .text + .to_lowercase() + .cmp(&b.author.author_name.text.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, "Author Name"); + } + + #[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_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()[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, "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()[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, "Date"); + } + + #[test] + fn test_blocklist_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if BLOCKLIST_BLOCKS.contains(&active_readarr_block) { + assert!(BlocklistHandler::accepts(active_readarr_block)); + } else { + assert!(!BlocklistHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_blocklist_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_blocklist_item_id() { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.data.readarr_data.blocklist.select_index(Some(1)); + + let blocklist_item_id = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .extract_blocklist_item_id(); + + assert_eq!(blocklist_item_id, 2); + } + + #[test] + fn test_blocklist_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = true; + + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_not_ready_when_blocklist_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = false; + + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_ready_when_not_loading_and_blocklist_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = false; + app + .data + .readarr_data + .blocklist + .set_items(vec![BlocklistItem::default()]); + + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ); + + assert!(handler.is_ready()); + } + + fn blocklist_vec() -> Vec { + vec![ + BlocklistItem { + id: 3, + source_title: "test 2".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "AZW3".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + author: Author { + author_name: "Zora Neale".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + BlocklistItem { + id: 2, + source_title: "test 3".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "MOBI".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + author: Author { + author_name: "Mary Shelley".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + BlocklistItem { + id: 1, + source_title: "test 1".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + author: Author { + author_name: "".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/blocklist/mod.rs b/src/handlers/readarr_handlers/blocklist/mod.rs new file mode 100644 index 00000000..c24243e2 --- /dev/null +++ b/src/handlers/readarr_handlers/blocklist/mod.rs @@ -0,0 +1,224 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::Route; +use crate::models::readarr_models::BlocklistItem; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BLOCKLIST_BLOCKS}; +use crate::models::stateful_table::SortOption; +use crate::network::readarr_network::ReadarrEvent; + +#[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_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl BlocklistHandler<'_, '_> { + fn extract_blocklist_item_id(&self) -> i64 { + self.app.data.readarr_data.blocklist.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BlocklistHandler<'a, 'b> { + fn handle(&mut self) { + let blocklist_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::Blocklist.into()) + .sorting_block(ActiveReadarrBlock::BlocklistSortPrompt.into()) + .sort_options(blocklist_sorting_options()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.blocklist, + blocklist_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + BLOCKLIST_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> Self { + BlocklistHandler { + key, + app, + active_readarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.blocklist.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) { + if self.active_readarr_block == ActiveReadarrBlock::Blocklist { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteBlocklistItemPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Blocklist => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::DeleteBlocklistItemPrompt + | ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + handle_prompt_toggle(self.app, self.key) + } + _ => {} + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteBlocklistItemPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::DeleteBlocklistItem(self.extract_blocklist_item_id()), + ); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::ClearBlocklist); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::Blocklist => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::BlocklistItemDetails.into()); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteBlocklistItemPrompt + | ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::BlocklistItemDetails | ActiveReadarrBlock::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_readarr_block { + ActiveReadarrBlock::Blocklist => match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ if matches_key!(clear, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::BlocklistClearAllItemsPrompt.into()); + } + _ => (), + }, + ActiveReadarrBlock::DeleteBlocklistItemPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::DeleteBlocklistItem(self.extract_blocklist_item_id()), + ); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::ClearBlocklist); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +fn blocklist_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Author Name", + cmp_fn: Some(|a, b| { + a.author + .author_name + .text + .to_lowercase() + .cmp(&b.author.author_name.text.to_lowercase()) + }), + }, + SortOption { + name: "Source Title", + cmp_fn: Some(|a, b| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }), + }, + SortOption { + name: "Date", + cmp_fn: Some(|a, b| a.date.cmp(&b.date)), + }, + ] +} diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 477598d5..2de29fc0 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -1,4 +1,5 @@ use super::KeyEventHandler; +use crate::handlers::readarr_handlers::blocklist::BlocklistHandler; use crate::handlers::readarr_handlers::library::LibraryHandler; use crate::models::Route; use crate::{ @@ -6,6 +7,7 @@ use crate::{ models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, }; +mod blocklist; mod history; mod library; @@ -26,6 +28,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, _ if LibraryHandler::accepts(self.active_readarr_block) => { LibraryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); } + _ if BlocklistHandler::accepts(self.active_readarr_block) => { + BlocklistHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } _ => self.handle_key_event(), } } diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index d467d874..d0806dd2 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -176,4 +176,22 @@ mod tests { active_readarr_block ); } + + #[rstest] + fn test_delegates_blocklist_blocks_to_blocklist_handler( + #[values( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistItemDetails, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ActiveReadarrBlock::BlocklistSortPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::Blocklist, + active_readarr_block + ); + } } From adf0896b7d0a8747b9698e4ae966f4ecd7275641 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 4 Sep 2026 00:07:33 -0600 Subject: [PATCH 089/170] feat(readarr): add the downloads handler --- .../downloads/downloads_handler_tests.rs | 502 ++++++++++++++++++ .../readarr_handlers/downloads/mod.rs | 171 ++++++ src/handlers/readarr_handlers/mod.rs | 5 + .../readarr_handlers/readarr_handler_tests.rs | 16 + 4 files changed, 694 insertions(+) create mode 100644 src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/downloads/mod.rs diff --git a/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs new file mode 100644 index 00000000..b8666b0e --- /dev/null +++ b/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs @@ -0,0 +1,502 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::Number; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::downloads::DownloadsHandler; + use crate::models::readarr_models::{DownloadRecord, DownloadStatus}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; + + 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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Downloads, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteDownloadPrompt.into()); + } + + #[test] + fn test_delete_download_prompt_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Downloads, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Downloads.into() + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_downloads_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(1); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Authors.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Authors.into()); + } + + #[rstest] + fn test_downloads_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(1); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Blocklist.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_downloads_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::DeleteDownloadPrompt, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + DownloadsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + DownloadsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + use crate::network::readarr_network::ReadarrEvent; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[rstest] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ReadarrEvent::DeleteDownload(3) + )] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::UpdateDownloadsPrompt, + ReadarrEvent::UpdateDownloads + )] + fn test_downloads_prompt_confirm_submit( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + DownloadsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + + #[rstest] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt + )] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + fn test_downloads_prompt_decline_submit( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + DownloadsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, base_route.into()); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt + )] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + fn test_downloads_prompt_blocks_esc( + #[case] base_block: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(base_block.into()); + app.push_navigation_stack(prompt_block.into()); + app.data.readarr_data.prompt_confirm = true; + + DownloadsHandler::new(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert_navigation_popped!(app, base_block.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + DownloadsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::Downloads, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Downloads.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::network::readarr_network::ReadarrEvent; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + + #[test] + fn test_update_downloads_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::UpdateDownloadsPrompt.into()); + } + + #[test] + fn test_update_downloads_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Downloads.into() + ); + } + + #[test] + fn test_refresh_downloads_key() { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::Downloads.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_downloads_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Downloads.into() + ); + assert!(!app.should_refresh); + } + + #[rstest] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ReadarrEvent::DeleteDownload(3) + )] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::UpdateDownloadsPrompt, + ReadarrEvent::UpdateDownloads + )] + fn test_downloads_prompt_confirm( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + } + + #[test] + fn test_downloads_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DOWNLOADS_BLOCKS.contains(&active_readarr_block) { + assert!(DownloadsHandler::accepts(active_readarr_block)); + } else { + assert!(!DownloadsHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_downloads_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_download_id() { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.data.readarr_data.downloads.select_index(Some(1)); + + let download_id = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .extract_download_id(); + + assert_eq!(download_id, 2); + } + + #[test] + fn test_downloads_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = true; + + let handler = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_downloads_handler_not_ready_when_downloads_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = false; + + let handler = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_downloads_handler_ready_when_not_loading_and_downloads_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = false; + app + .data + .readarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + + let handler = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ); + + assert!(handler.is_ready()); + } + + fn downloads_vec() -> Vec { + vec![ + DownloadRecord { + title: "Test Book Download Three".to_owned(), + status: DownloadStatus::Downloading, + id: 3, + book_id: Some(Number::from(13)), + author_id: Some(Number::from(23)), + size: 3000.0, + sizeleft: 750.0, + output_path: Some("/nfs/nzbget/completed/books/Zora Neale - Test Book Three".into()), + indexer: "test-indexer-three".to_owned(), + download_client: Some("NZBGet".to_owned()), + }, + DownloadRecord { + title: "Test Book Download Two".to_owned(), + status: DownloadStatus::Queued, + id: 2, + book_id: Some(Number::from(12)), + author_id: Some(Number::from(22)), + size: 2000.0, + sizeleft: 500.0, + output_path: Some("/nfs/nzbget/completed/books/Mary Shelley - Test Book Two".into()), + indexer: "test-indexer-two".to_owned(), + download_client: Some("Transmission".to_owned()), + }, + DownloadRecord { + title: "Test Book Download One".to_owned(), + status: DownloadStatus::Paused, + id: 1, + book_id: Some(Number::from(11)), + author_id: Some(Number::from(21)), + size: 1000.0, + sizeleft: 250.0, + output_path: Some("/nfs/nzbget/completed/books/Ursula Le Guin - Test Book One".into()), + indexer: "test-indexer-one".to_owned(), + download_client: Some("SABnzbd".to_owned()), + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/downloads/mod.rs b/src/handlers/readarr_handlers/downloads/mod.rs new file mode 100644 index 00000000..3e0bf18d --- /dev/null +++ b/src/handlers/readarr_handlers/downloads/mod.rs @@ -0,0 +1,171 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; +use crate::network::readarr_network::ReadarrEvent; + +#[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_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl DownloadsHandler<'_, '_> { + fn extract_download_id(&self) -> i64 { + self.app.data.readarr_data.downloads.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for DownloadsHandler<'a, 'b> { + fn handle(&mut self) { + let downloads_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::Downloads.into()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.downloads, + downloads_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + DOWNLOADS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> DownloadsHandler<'a, 'b> { + DownloadsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.downloads.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) { + if self.active_readarr_block == ActiveReadarrBlock::Downloads { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteDownloadPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Downloads => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::DeleteDownloadPrompt | ActiveReadarrBlock::UpdateDownloadsPrompt => { + handle_prompt_toggle(self.app, self.key) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteDownloadPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteDownload(self.extract_download_id())); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::UpdateDownloadsPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateDownloads); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteDownloadPrompt | ActiveReadarrBlock::UpdateDownloadsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::Downloads => match self.key { + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateDownloadsPrompt.into()); + } + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + }, + ActiveReadarrBlock::DeleteDownloadPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteDownload(self.extract_download_id())); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::UpdateDownloadsPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateDownloads); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 2de29fc0..6233cffc 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -1,5 +1,6 @@ use super::KeyEventHandler; use crate::handlers::readarr_handlers::blocklist::BlocklistHandler; +use crate::handlers::readarr_handlers::downloads::DownloadsHandler; use crate::handlers::readarr_handlers::library::LibraryHandler; use crate::models::Route; use crate::{ @@ -8,6 +9,7 @@ use crate::{ }; mod blocklist; +mod downloads; mod history; mod library; @@ -28,6 +30,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, _ if LibraryHandler::accepts(self.active_readarr_block) => { LibraryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); } + _ if DownloadsHandler::accepts(self.active_readarr_block) => { + DownloadsHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } _ if BlocklistHandler::accepts(self.active_readarr_block) => { BlocklistHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); } diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index d0806dd2..6d8445c3 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -194,4 +194,20 @@ mod tests { active_readarr_block ); } + + #[rstest] + fn test_delegates_downloads_blocks_to_downloads_handler( + #[values( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::Downloads, + active_readarr_block + ); + } } From bad43d138394fe93fc0dbcfed52a2b1f8b4691ce Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 4 Sep 2026 00:22:04 -0600 Subject: [PATCH 090/170] feat(readarr): add the history handler --- .../history/history_handler_tests.rs | 305 +++++++++++++++++- src/handlers/readarr_handlers/history/mod.rs | 120 +++++++ src/handlers/readarr_handlers/mod.rs | 4 + .../readarr_handlers/readarr_handler_tests.rs | 20 ++ 4 files changed, 448 insertions(+), 1 deletion(-) diff --git a/src/handlers/readarr_handlers/history/history_handler_tests.rs b/src/handlers/readarr_handlers/history/history_handler_tests.rs index 7a29b745..66ed7d16 100644 --- a/src/handlers/readarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/readarr_handlers/history/history_handler_tests.rs @@ -4,9 +4,17 @@ mod tests { use chrono::DateTime; use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; - use crate::handlers::readarr_handlers::history::history_sorting_options; + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::history::{HistoryHandler, history_sorting_options}; use crate::models::readarr_models::{ReadarrHistoryEventType, ReadarrHistoryItem}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; use crate::models::servarr_models::{Quality, QualityWrapper}; #[test] @@ -82,6 +90,301 @@ mod tests { assert_str_eq!(sort_option.name, "Date"); } + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_history_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(3); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Blocklist.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_history_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(3); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::RootFolders.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::RootFolders.into()); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_history_submit() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::History, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::HistoryItemDetails.into()); + } + + #[test] + fn test_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::History.into()); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_esc_history_item_details() { + let mut app = App::test_default(); + app + .data + .readarr_data + .history + .set_items(vec![ReadarrHistoryItem::default()]); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.push_navigation_stack(ActiveReadarrBlock::HistoryItemDetails.into()); + + HistoryHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::HistoryItemDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::History.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app + .data + .readarr_data + .history + .set_items(vec![ReadarrHistoryItem::default()]); + + HistoryHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::History.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_refresh_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::History.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_history_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::History.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_search_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchHistory.into()); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::FilterHistory.into()); + } + } + + #[test] + fn test_history_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if HISTORY_BLOCKS.contains(&active_readarr_block) { + assert!(HistoryHandler::accepts(active_readarr_block)); + } else { + assert!(!HistoryHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_history_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_history_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = true; + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_not_ready_when_history_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = false; + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_ready_when_not_loading_and_history_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = false; + app + .data + .readarr_data + .history + .set_items(vec![ReadarrHistoryItem::default()]); + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::History, + None, + ); + + assert!(handler.is_ready()); + } + fn history_vec() -> Vec { vec![ ReadarrHistoryItem { diff --git a/src/handlers/readarr_handlers/history/mod.rs b/src/handlers/readarr_handlers/history/mod.rs index 5ee320e4..128349de 100644 --- a/src/handlers/readarr_handlers/history/mod.rs +++ b/src/handlers/readarr_handlers/history/mod.rs @@ -1,10 +1,130 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors}; +use crate::matches_key; +use crate::models::Route; use crate::models::readarr_models::ReadarrHistoryItem; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; use crate::models::stateful_table::SortOption; #[cfg(test)] #[path = "history_handler_tests.rs"] mod history_handler_tests; +pub(super) struct HistoryHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for HistoryHandler<'a, 'b> { + fn handle(&mut self) { + let history_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::History.into()) + .sorting_block(ActiveReadarrBlock::HistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchHistory.into()) + .search_error_block(ActiveReadarrBlock::SearchHistoryError.into()) + .search_field_fn(|history| &history.source_title.text) + .filtering_block(ActiveReadarrBlock::FilterHistory.into()) + .filter_error_block(ActiveReadarrBlock::FilterHistoryError.into()) + .filter_field_fn(|history| &history.source_title.text); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.history, + history_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + HISTORY_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> Self { + HistoryHandler { + key, + app, + active_readarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.history.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::History { + handle_change_tab_left_right_keys(self.app, self.key) + } + } + + fn handle_submit(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::History { + self + .app + .push_navigation_stack(ActiveReadarrBlock::HistoryItemDetails.into()); + } + } + + fn handle_esc(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::HistoryItemDetails { + self.app.pop_navigation_stack(); + } else { + handle_clear_errors(self.app); + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + if self.active_readarr_block == ActiveReadarrBlock::History { + match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + } + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + pub(in crate::handlers::readarr_handlers) fn history_sorting_options() -> Vec> { vec![ diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 6233cffc..d571c990 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -1,6 +1,7 @@ use super::KeyEventHandler; use crate::handlers::readarr_handlers::blocklist::BlocklistHandler; use crate::handlers::readarr_handlers::downloads::DownloadsHandler; +use crate::handlers::readarr_handlers::history::HistoryHandler; use crate::handlers::readarr_handlers::library::LibraryHandler; use crate::models::Route; use crate::{ @@ -36,6 +37,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, _ if BlocklistHandler::accepts(self.active_readarr_block) => { BlocklistHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); } + _ if HistoryHandler::accepts(self.active_readarr_block) => { + HistoryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } _ => self.handle_key_event(), } } diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index 6d8445c3..0c1d1d99 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -210,4 +210,24 @@ mod tests { active_readarr_block ); } + + #[rstest] + fn test_delegates_history_blocks_to_history_handler( + #[values( + ActiveReadarrBlock::History, + ActiveReadarrBlock::HistoryItemDetails, + ActiveReadarrBlock::HistorySortPrompt, + ActiveReadarrBlock::SearchHistory, + ActiveReadarrBlock::SearchHistoryError, + ActiveReadarrBlock::FilterHistory, + ActiveReadarrBlock::FilterHistoryError + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::History, + active_readarr_block + ); + } } From f73959b73f4485c83fa28cd425f395c1f236f1ec Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 4 Sep 2026 00:34:46 -0600 Subject: [PATCH 091/170] feat(readarr): add the blocklist UI Render the Readarr blocklist table with the item details popup, the remove item prompt and the clear blocklist prompt, and dispatch it from ReadarrUi so the tab is reachable. The snapshot suite covers every block in BLOCKLIST_BLOCKS plus loading and empty states. A second set of snapshots seeds a two row fixture with distinct values in every column and selects the second row, so that reading items[0] instead of the current selection cannot pass. --- .../blocklist/blocklist_ui_tests.rs | 147 +++++++++++++++++ src/ui/readarr_ui/blocklist/mod.rs | 156 ++++++++++++++++++ ...cklist_second_item_selected_Blocklist.snap | 8 + ...nd_item_selected_BlocklistItemDetails.snap | 34 ++++ ...em_selected_DeleteBlocklistItemPrompt.snap | 38 +++++ ...apshot_tests__blocklist_tab_Blocklist.snap | 7 + ...list_tab_BlocklistClearAllItemsPrompt.snap | 34 ++++ ...s__blocklist_tab_BlocklistItemDetails.snap | 34 ++++ ...ts__blocklist_tab_BlocklistSortPrompt.snap | 42 +++++ ...ocklist_tab_DeleteBlocklistItemPrompt.snap | 38 +++++ ...hot_tests__blocklist_ui_renders_empty.snap | 5 + ...t_tests__blocklist_ui_renders_loading.snap | 8 + src/ui/readarr_ui/mod.rs | 3 + ...napshot_tests__readarr_tabs_Blocklist.snap | 6 +- 14 files changed, 557 insertions(+), 3 deletions(-) create mode 100644 src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs create mode 100644 src/ui/readarr_ui/blocklist/mod.rs create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_Blocklist.snap create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_BlocklistItemDetails.snap create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_DeleteBlocklistItemPrompt.snap create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap create mode 100644 src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap diff --git a/src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs b/src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs new file mode 100644 index 00000000..b05240fb --- /dev/null +++ b/src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs @@ -0,0 +1,147 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BLOCKLIST_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::blocklist::BlocklistUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_blocklist_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if BLOCKLIST_BLOCKS.contains(&active_readarr_block) { + assert!(BlocklistUi::accepts(active_readarr_block.into())); + } else { + assert!(!BlocklistUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use chrono::DateTime; + use rstest::rstest; + + use crate::models::readarr_models::{Author, BlocklistItem}; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_blocklist_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_blocklist_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_blocklist_ui_renders( + #[values( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistItemDetails, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ActiveReadarrBlock::BlocklistSortPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("blocklist_tab_{active_readarr_block}"), output); + } + + #[rstest] + fn test_blocklist_ui_renders_second_item_selected( + #[values( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistItemDetails, + ActiveReadarrBlock::DeleteBlocklistItemPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.data.readarr_data.blocklist.select_index(Some(1)); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("blocklist_second_item_selected_{active_readarr_block}"), + output + ); + } + + fn blocklist_vec() -> Vec { + vec![ + BlocklistItem { + id: 1, + author_id: 1, + source_title: "First Source Title".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()), + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + message: "first blocklist message".to_owned(), + author: Author { + id: 1, + author_name: "First Author".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + BlocklistItem { + id: 2, + author_id: 2, + source_title: "Second Source Title".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "MOBI".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-08-14T09:03:47Z").unwrap()), + protocol: "torrent".to_owned(), + indexer: "Nyaa".to_owned(), + message: "second blocklist message".to_owned(), + author: Author { + id: 2, + author_name: "Second Author".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + ] + } + } +} diff --git a/src/ui/readarr_ui/blocklist/mod.rs b/src/ui/readarr_ui/blocklist/mod.rs new file mode 100644 index 00000000..f706424f --- /dev/null +++ b/src/ui/readarr_ui/blocklist/mod.rs @@ -0,0 +1,156 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::BlocklistItem; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BLOCKLIST_BLOCKS}; +use crate::ui::DrawUi; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +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; +use crate::ui::widgets::popup::{Popup, Size}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::style::Stylize; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Cell, Row}; + +#[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::Readarr(active_readarr_block, _) = route { + return BLOCKLIST_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_blocklist_table(f, app, area); + + match active_readarr_block { + ActiveReadarrBlock::BlocklistItemDetails => { + draw_blocklist_item_details_popup(f, app); + } + ActiveReadarrBlock::DeleteBlocklistItemPrompt => { + let prompt = format!( + "Do you want to remove this item from your blocklist: \n{}?", + app + .data + .readarr_data + .blocklist + .current_selection() + .source_title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Remove Item from Blocklist") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Clear Blocklist") + .prompt("Do you want to clear your blocklist?") + .yes_no_value(app.data.readarr_data.prompt_confirm); + + 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::Readarr(active_readarr_block, _) = app.get_current_route() { + let blocklist_row_mapping = |blocklist_item: &BlocklistItem| { + let BlocklistItem { + source_title, + author, + quality, + date, + .. + } = blocklist_item; + + let title = author.author_name.text.to_owned(); + + Row::new(vec![ + Cell::from(title), + Cell::from(source_title.to_owned()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let blocklist_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.blocklist), + blocklist_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::BlocklistSortPrompt) + .headers(["Author Name", "Source Title", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(27), + Constraint::Percentage(43), + Constraint::Percentage(13), + Constraint::Percentage(17), + ]); + + f.render_widget(blocklist_table, area); + } +} + +fn draw_blocklist_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.readarr_data.blocklist.items.is_empty() { + BlocklistItem::default() + } else { + app.data.readarr_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(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); +} diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_Blocklist.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_Blocklist.snap new file mode 100644 index 00000000..a372efdd --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_Blocklist.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name Source Title Quality Date + First Author First Source Title EPUB 2023-05-20 21:29:16 UTC +=> Second Author Second Source Title MOBI 2024-08-14 09:03:47 UTC diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_BlocklistItemDetails.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_BlocklistItemDetails.snap new file mode 100644 index 00000000..f600120f --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_BlocklistItemDetails.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name Source Title Quality Date + First Author First Source Title EPUB 2023-05-20 21:29:16 UTC +=> Second Author Second Source Title MOBI 2024-08-14 09:03:47 UTC + + + + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Name: Second Source Title │ + │Protocol: torrent │ + │Indexer: Nyaa │ + │Message: second blocklist message │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_DeleteBlocklistItemPrompt.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_DeleteBlocklistItemPrompt.snap new file mode 100644 index 00000000..afe15f3d --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_DeleteBlocklistItemPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name Source Title Quality Date + First Author First Source Title EPUB 2023-05-20 21:29:16 UTC +=> Second Author Second Source Title MOBI 2024-08-14 09:03:47 UTC + + + + + + + + + + + + + ╭────────────── Remove Item from Blocklist ───────────────╮ + │ Do you want to remove this item from your blocklist: │ + │ Second Source Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap new file mode 100644 index 00000000..af56634e --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name ▼ Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap new file mode 100644 index 00000000..b8f767b5 --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name ▼ Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + ╭────── Clear Blocklist ──────╮ + │ Do you want to clear your │ + │ blocklist? │ + │ │ + │ │ + │ │ + │╭──────────────╮╭─────────────╮│ + ││ Yes ││ No ││ + │╰──────────────╯╰─────────────╯│ + ╰───────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap new file mode 100644 index 00000000..c8183d3d --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name ▼ Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Name: Test Source Title │ + │Protocol: usenet │ + │Indexer: DrunkenSlug │ + │Message: test message │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap new file mode 100644 index 00000000..7dab724f --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + ╭───────────────────────────────╮ + │Something │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap new file mode 100644 index 00000000..3ae052bc --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name ▼ Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + ╭────────────── Remove Item from Blocklist ───────────────╮ + │ Do you want to remove this item from your blocklist: │ + │ Test Source Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap new file mode 100644 index 00000000..4971b8c6 --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap new file mode 100644 index 00000000..30938176 --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs index 063f50f3..e9922aea 100644 --- a/src/ui/readarr_ui/mod.rs +++ b/src/ui/readarr_ui/mod.rs @@ -21,6 +21,7 @@ use super::{ }, widgets::loading_block::LoadingBlock, }; +use crate::ui::readarr_ui::blocklist::BlocklistUi; use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; use crate::{ app::App, @@ -34,6 +35,7 @@ use crate::{ utils::convert_to_gb, }; +mod blocklist; mod library; mod readarr_ui_utils; @@ -54,6 +56,7 @@ impl DrawUi for ReadarrUi { match route { _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area), _ => (), } } diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap index d3f01721..39570c7c 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap @@ -4,9 +4,9 @@ expression: output --- ╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ -│ │ -│ │ -│ │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Author Name ▼ Source Title Quality Date │ +│=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC │ │ │ │ │ │ │ From 91c2613e659cfa6aaee7a906055f3537ad227f2e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 4 Sep 2026 00:39:33 -0600 Subject: [PATCH 092/170] feat(readarr): add the downloads UI Render the Readarr downloads table with the cancel download prompt and the update downloads prompt, and dispatch it from ReadarrUi so the tab is reachable. The snapshot suite covers every block in DOWNLOADS_BLOCKS plus loading and empty states. A second set of snapshots seeds a two row fixture and selects the second row. Its rows deliberately have different completion ratios, so that inverting the percentage calculation is caught rather than cancelling out against a half finished download. --- .../downloads/downloads_ui_tests.rs | 127 +++++++++++++++ src/ui/readarr_ui/downloads/mod.rs | 146 ++++++++++++++++++ ...nd_item_selected_DeleteDownloadPrompt.snap | 38 +++++ ...nloads_second_item_selected_Downloads.snap | 8 + ...ts__downloads_ui_DeleteDownloadPrompt.snap | 38 +++++ ...napshot_tests__downloads_ui_Downloads.snap | 7 + ...s__downloads_ui_UpdateDownloadsPrompt.snap | 38 +++++ ...hot_tests__downloads_ui_renders_empty.snap | 5 + ...t_tests__downloads_ui_renders_loading.snap | 8 + src/ui/readarr_ui/mod.rs | 3 + ...napshot_tests__readarr_tabs_Downloads.snap | 6 +- 11 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 src/ui/readarr_ui/downloads/downloads_ui_tests.rs create mode 100644 src/ui/readarr_ui/downloads/mod.rs create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap diff --git a/src/ui/readarr_ui/downloads/downloads_ui_tests.rs b/src/ui/readarr_ui/downloads/downloads_ui_tests.rs new file mode 100644 index 00000000..565b470a --- /dev/null +++ b/src/ui/readarr_ui/downloads/downloads_ui_tests.rs @@ -0,0 +1,127 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::downloads::DownloadsUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_downloads_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DOWNLOADS_BLOCKS.contains(&active_readarr_block) { + assert!(DownloadsUi::accepts(active_readarr_block.into())); + } else { + assert!(!DownloadsUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use rstest::rstest; + use serde_json::Number; + + use crate::models::readarr_models::{DownloadRecord, DownloadStatus}; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_downloads_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_downloads_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_downloads_ui_renders( + #[values( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("downloads_ui_{active_readarr_block}"), output); + } + + #[rstest] + fn test_downloads_ui_renders_second_item_selected( + #[values( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.data.readarr_data.downloads.select_index(Some(1)); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("downloads_second_item_selected_{active_readarr_block}"), + output + ); + } + + fn downloads_vec() -> Vec { + vec![ + DownloadRecord { + id: 1, + title: "First Download Title".to_owned(), + status: DownloadStatus::Downloading, + book_id: Some(Number::from(1)), + author_id: Some(Number::from(1)), + size: 3543348019f64, + sizeleft: 1771674009f64, + output_path: Some("/nfs/books/First Author".into()), + indexer: "DrunkenSlug".to_owned(), + download_client: Some("sabnzbd".to_owned()), + }, + DownloadRecord { + id: 2, + title: "Second Download Title".to_owned(), + status: DownloadStatus::Paused, + book_id: Some(Number::from(2)), + author_id: Some(Number::from(2)), + size: 1073741824f64, + sizeleft: 268435456f64, + output_path: Some("/nfs/books/Second Author".into()), + indexer: "Nyaa".to_owned(), + download_client: Some("transmission".to_owned()), + }, + ] + } + } +} diff --git a/src/ui/readarr_ui/downloads/mod.rs b/src/ui/readarr_ui/downloads/mod.rs new file mode 100644 index 00000000..ac1a7c6e --- /dev/null +++ b/src/ui/readarr_ui/downloads/mod.rs @@ -0,0 +1,146 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; + +use crate::app::App; +use crate::models::readarr_models::DownloadRecord; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; +use crate::models::{HorizontallyScrollableText, Route}; +use crate::ui::DrawUi; +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::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::Readarr(active_readarr_block, _) = route { + return DOWNLOADS_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_downloads(f, app, area); + + match active_readarr_block { + ActiveReadarrBlock::DeleteDownloadPrompt => { + let prompt = format!( + "Do you really want to delete this download: \n{}?", + app.data.readarr_data.downloads.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Cancel Download") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::UpdateDownloadsPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update Downloads") + .prompt("Do you want to update your downloads?") + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), + } + } + } +} + +fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = if app.data.readarr_data.downloads.items.is_empty() { + DownloadRecord::default() + } else { + app.data.readarr_data.downloads.current_selection().clone() + }; + + 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.should_text_scroll, + ); + } + + 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 + .as_ref() + .unwrap_or(&String::new()) + .to_owned(), + ), + ]) + .primary() + }; + let downloads_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.downloads), + downloads_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .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/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap new file mode 100644 index 00000000..f0c11f76 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client + First Download Title 50% 3.30 GB /nfs/books/First Author DrunkenSlug sabnzbd +=> Second Download Title 75% 1.00 GB /nfs/books/Second Author Nyaa transmission + + + + + + + + + + + + + ╭──────────────────── Cancel Download ────────────────────╮ + │ Do you really want to delete this download: │ + │ Second Download Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap new file mode 100644 index 00000000..4c7f4950 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client + First Download Title 50% 3.30 GB /nfs/books/First Author DrunkenSlug sabnzbd +=> Second Download Title 75% 1.00 GB /nfs/books/Second Author Nyaa transmission diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap new file mode 100644 index 00000000..697b474e --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client +=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission + + + + + + + + + + + + + + ╭──────────────────── Cancel Download ────────────────────╮ + │ Do you really want to delete this download: │ + │ Test Download Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap new file mode 100644 index 00000000..fb117252 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client +=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap new file mode 100644 index 00000000..7c0ecc17 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client +=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission + + + + + + + + + + + + + + ╭─────────────────── Update Downloads ────────────────────╮ + │ Do you want to update your downloads? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap new file mode 100644 index 00000000..982f0817 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap new file mode 100644 index 00000000..c74ac942 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs index e9922aea..5477ab33 100644 --- a/src/ui/readarr_ui/mod.rs +++ b/src/ui/readarr_ui/mod.rs @@ -22,6 +22,7 @@ use super::{ widgets::loading_block::LoadingBlock, }; use crate::ui::readarr_ui::blocklist::BlocklistUi; +use crate::ui::readarr_ui::downloads::DownloadsUi; use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; use crate::{ app::App, @@ -36,6 +37,7 @@ use crate::{ }; mod blocklist; +mod downloads; mod library; mod readarr_ui_utils; @@ -56,6 +58,7 @@ impl DrawUi for ReadarrUi { 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), _ => (), } diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap index d3f01721..86d9b348 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap @@ -4,9 +4,9 @@ expression: output --- ╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ -│ │ -│ │ -│ │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Title Percent Complete Size Output Path Indexer Download Client │ +│=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission │ │ │ │ │ │ │ From ee07a0289f5e5c4e5f214f944a6c7ea9ebd2a431 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 4 Sep 2026 09:49:36 -0600 Subject: [PATCH 093/170] feat(readarr): add the history UI Renders the history table, its sort, search and filter prompts, both empty result popups and the item details view. Snapshots cover a non-default selection so that reading a row from a fixed index rather than the current selection cannot pass unnoticed. --- src/ui/readarr_ui/history/history_ui_tests.rs | 154 ++++++++++++++++++ src/ui/readarr_ui/history/mod.rs | 122 ++++++++++++++ ...shot_tests__empty_history_tab_History.snap | 5 + ..._empty_history_tab_HistoryItemDetails.snap | 38 +++++ ..._history_second_item_selected_History.snap | 8 + ...cond_item_selected_HistoryItemDetails.snap | 38 +++++ ...shot_tests__history_tab_FilterHistory.snap | 28 ++++ ...tests__history_tab_FilterHistoryError.snap | 31 ++++ ...__snapshot_tests__history_tab_History.snap | 7 + ...tests__history_tab_HistoryItemDetails.snap | 38 +++++ ..._tests__history_tab_HistorySortPrompt.snap | 42 +++++ ...shot_tests__history_tab_SearchHistory.snap | 28 ++++ ...tests__history_tab_SearchHistoryError.snap | 31 ++++ ...hot_tests__history_ui_renders_loading.snap | 8 + src/ui/readarr_ui/mod.rs | 3 + ..._snapshot_tests__readarr_tabs_History.snap | 6 +- 16 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 src/ui/readarr_ui/history/history_ui_tests.rs create mode 100644 src/ui/readarr_ui/history/mod.rs create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_History.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_HistoryItemDetails.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_History.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_HistoryItemDetails.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap create mode 100644 src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap diff --git a/src/ui/readarr_ui/history/history_ui_tests.rs b/src/ui/readarr_ui/history/history_ui_tests.rs new file mode 100644 index 00000000..77615ae5 --- /dev/null +++ b/src/ui/readarr_ui/history/history_ui_tests.rs @@ -0,0 +1,154 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::history::HistoryUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_history_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if HISTORY_BLOCKS.contains(&active_readarr_block) { + assert!(HistoryUi::accepts(active_readarr_block.into())); + } else { + assert!(!HistoryUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use chrono::DateTime; + use rstest::rstest; + + use crate::models::readarr_models::{ + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_history_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_history_ui_renders_empty( + #[values(ActiveReadarrBlock::History, ActiveReadarrBlock::HistoryItemDetails)] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("empty_history_tab_{active_readarr_block}"), output); + } + + #[rstest] + fn test_history_ui_renders( + #[values( + ActiveReadarrBlock::History, + ActiveReadarrBlock::HistoryItemDetails, + ActiveReadarrBlock::HistorySortPrompt, + ActiveReadarrBlock::FilterHistory, + ActiveReadarrBlock::FilterHistoryError, + ActiveReadarrBlock::SearchHistory, + ActiveReadarrBlock::SearchHistoryError + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("history_tab_{active_readarr_block}"), output); + } + + #[rstest] + fn test_history_ui_renders_second_item_selected( + #[values(ActiveReadarrBlock::History, ActiveReadarrBlock::HistoryItemDetails)] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.data.readarr_data.history.select_index(Some(1)); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("history_second_item_selected_{active_readarr_block}"), + output + ); + } + + fn history_vec() -> Vec { + vec![ + ReadarrHistoryItem { + id: 1, + author_id: 1, + book_id: 1, + source_title: "First Source Title".into(), + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()), + event_type: ReadarrHistoryEventType::Grabbed, + data: ReadarrHistoryData { + indexer: Some("DrunkenSlug".to_owned()), + nzb_info_url: Some("https://drunkenslug.com/details/first".to_owned()), + release_group: Some("FirstReleaseGroup".to_owned()), + age: Some("3".to_owned()), + published_date: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-18T08:15:00Z").unwrap(), + )), + download_client_name: Some("nzbget".to_owned()), + ..ReadarrHistoryData::default() + }, + }, + ReadarrHistoryItem { + id: 2, + author_id: 2, + book_id: 2, + source_title: "Second Source Title".into(), + quality: QualityWrapper { + quality: Quality { + name: "MOBI".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-08-14T09:03:47Z").unwrap()), + event_type: ReadarrHistoryEventType::DownloadFailed, + data: ReadarrHistoryData { + download_client_name: Some("sabnzbd".to_owned()), + message: Some("The download client reported a failure".to_owned()), + release_group: Some("SecondReleaseGroup".to_owned()), + indexer: Some("Nyaa".to_owned()), + ..ReadarrHistoryData::default() + }, + }, + ] + } + } +} diff --git a/src/ui/readarr_ui/history/mod.rs b/src/ui/readarr_ui/history/mod.rs new file mode 100644 index 00000000..eddaef27 --- /dev/null +++ b/src/ui/readarr_ui/history/mod.rs @@ -0,0 +1,122 @@ +use super::readarr_ui_utils::create_history_event_details; +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::ReadarrHistoryItem; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; +use crate::ui::DrawUi; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +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 ratatui::Frame; +use ratatui::layout::{Alignment, Rect}; +use ratatui::prelude::Constraint; +use ratatui::text::Text; +use ratatui::widgets::{Cell, Row}; + +#[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::Readarr(active_readarr_block, _) = route { + return HISTORY_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_history_table(f, app, area); + + if active_readarr_block == ActiveReadarrBlock::HistoryItemDetails { + 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.readarr_data.history.items.is_empty() { + ReadarrHistoryItem::default() + } else { + app.data.readarr_data.history.current_selection().clone() + }; + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &ReadarrHistoryItem| { + let ReadarrHistoryItem { + source_title, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 50), + current_selection == *history_item, + app.should_text_scroll, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let history_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.history), + history_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::HistorySortPrompt) + .searching(active_readarr_block == ActiveReadarrBlock::SearchHistory) + .search_produced_empty_results(active_readarr_block == ActiveReadarrBlock::SearchHistoryError) + .filtering(active_readarr_block == ActiveReadarrBlock::FilterHistory) + .filter_produced_empty_results(active_readarr_block == ActiveReadarrBlock::FilterHistoryError) + .headers(["Source Title", "Event Type", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(18), + Constraint::Percentage(12), + Constraint::Percentage(20), + ]); + + if [ + ActiveReadarrBlock::SearchHistory, + ActiveReadarrBlock::FilterHistory, + ] + .contains(&active_readarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.readarr_data.history.items.is_empty() { + ReadarrHistoryItem::default() + } else { + app.data.readarr_data.history.current_selection().clone() + }; + + let line_vec = create_history_event_details(current_selection); + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowLongMessage), f.area()); +} diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_History.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_History.snap new file mode 100644 index 00000000..f7b5e42a --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_History.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_HistoryItemDetails.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_HistoryItemDetails.snap new file mode 100644 index 00000000..bc14d79e --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_HistoryItemDetails.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Source Title: │ + │Event Type: unknown │ + │Quality: │ + │Date: 1970-01-01 00:00:00 UTC │ + │No additional details available. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_History.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_History.snap new file mode 100644 index 00000000..26950843 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_History.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title Event Type Quality Date + First Source Title grabbed EPUB 2023-05-20 21:29:16 UTC +=> Second Source Title downloadFailed MOBI 2024-08-14 09:03:47 UTC diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_HistoryItemDetails.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_HistoryItemDetails.snap new file mode 100644 index 00000000..2324e2f0 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_HistoryItemDetails.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title Event Type Quality Date + First Source Title grabbed EPUB 2023-05-20 21:29:16 UTC +=> Second Source Title downloadFailed MOBI 2024-08-14 09:03:47 UTC + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Source Title: Second Source Title │ + │Event Type: downloadFailed │ + │Quality: MOBI │ + │Date: 2024-08-14 09:03:47 UTC │ + │Download Client: sabnzbd │ + │Message: The download client reported a failure │ + │Release Group: SecondReleaseGroup │ + │Indexer: Nyaa │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap new file mode 100644 index 00000000..b0b4c285 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + + ╭───────────────── Filter ──────────────────╮ + │test filter │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap new file mode 100644 index 00000000..1f909902 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │The given filter produced empty results│ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap new file mode 100644 index 00000000..e3a516b0 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap new file mode 100644 index 00000000..79747de7 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Source Title: Test Source Title │ + │Event Type: grabbed │ + │Quality: EPUB │ + │Date: 2023-05-20 21:29:16 UTC │ + │Indexer: │ + │NZB Info URL: │ + │Release Group: │ + │Age: 0 days │ + │Published Date: 1970-01-01 00:00:00 UTC │ + │Download Client: │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap new file mode 100644 index 00000000..c6d2c733 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + ╭───────────────────────────────╮ + │Date │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap new file mode 100644 index 00000000..ce8e938c --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + + ╭───────────────── Search ──────────────────╮ + │test search │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap new file mode 100644 index 00000000..4d4ea130 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │ No items found matching search │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap new file mode 100644 index 00000000..bfafe99c --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs index 5477ab33..8539533f 100644 --- a/src/ui/readarr_ui/mod.rs +++ b/src/ui/readarr_ui/mod.rs @@ -3,6 +3,7 @@ use crate::ui::ui_test_utils::test_utils::Utc; use chrono::Duration; #[cfg(not(test))] use chrono::Utc; +use history::HistoryUi; use library::LibraryUi; use ratatui::{ Frame, @@ -38,6 +39,7 @@ use crate::{ mod blocklist; mod downloads; +mod history; mod library; mod readarr_ui_utils; @@ -60,6 +62,7 @@ impl DrawUi for ReadarrUi { _ 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), _ => (), } } diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap index d3f01721..f1ef5ee4 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap @@ -4,9 +4,9 @@ expression: output --- ╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ -│ │ -│ │ -│ │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Source Title ▼ Event Type Quality Date │ +│=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ │ │ │ │ │ │ From a4a3c775865609b9ba7e6169b05a6b13089479f8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 4 Sep 2026 12:11:00 -0600 Subject: [PATCH 094/170] feat(readarr): add the root folders handler --- src/handlers/readarr_handlers/mod.rs | 6 + .../readarr_handlers/readarr_handler_tests.rs | 15 + .../readarr_handlers/root_folders/mod.rs | 166 ++++++ .../root_folders_handler_tests.rs | 544 ++++++++++++++++++ 4 files changed, 731 insertions(+) create mode 100644 src/handlers/readarr_handlers/root_folders/mod.rs create mode 100644 src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index d571c990..50c47a3c 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -3,6 +3,7 @@ use crate::handlers::readarr_handlers::blocklist::BlocklistHandler; use crate::handlers::readarr_handlers::downloads::DownloadsHandler; use crate::handlers::readarr_handlers::history::HistoryHandler; use crate::handlers::readarr_handlers::library::LibraryHandler; +use crate::handlers::readarr_handlers::root_folders::RootFoldersHandler; use crate::models::Route; use crate::{ app::App, event::Key, matches_key, @@ -13,6 +14,7 @@ mod blocklist; mod downloads; mod history; mod library; +mod root_folders; #[cfg(test)] #[path = "readarr_handler_tests.rs"] @@ -40,6 +42,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, _ if HistoryHandler::accepts(self.active_readarr_block) => { HistoryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); } + _ if RootFoldersHandler::accepts(self.active_readarr_block) => { + RootFoldersHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } _ => self.handle_key_event(), } } diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index 0c1d1d99..063581e8 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -230,4 +230,19 @@ mod tests { active_readarr_block ); } + + #[rstest] + fn test_delegates_root_folders_blocks_to_root_folders_handler( + #[values( + ActiveReadarrBlock::RootFolders, + ActiveReadarrBlock::DeleteRootFolderPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::RootFolders, + active_readarr_block + ); + } } diff --git a/src/handlers/readarr_handlers/root_folders/mod.rs b/src/handlers/readarr_handlers/root_folders/mod.rs new file mode 100644 index 00000000..58f98703 --- /dev/null +++ b/src/handlers/readarr_handlers/root_folders/mod.rs @@ -0,0 +1,166 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ADD_ROOT_FOLDER_SELECTION_BLOCKS, ActiveReadarrBlock, ROOT_FOLDERS_BLOCKS, +}; +use crate::models::{BlockSelectionState, Route}; +use crate::network::readarr_network::ReadarrEvent; + +#[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_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl RootFoldersHandler<'_, '_> { + fn extract_root_folder_id(&self) -> i64 { + self + .app + .data + .readarr_data + .root_folders + .current_selection() + .id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for RootFoldersHandler<'a, 'b> { + fn handle(&mut self) { + let root_folders_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::RootFolders.into()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.root_folders, + root_folders_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + ROOT_FOLDERS_BLOCKS.contains(&active_block) || ADD_ROOT_FOLDER_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> RootFoldersHandler<'a, 'b> { + RootFoldersHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.root_folders.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) { + if self.active_readarr_block == ActiveReadarrBlock::RootFolders { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()) + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::RootFolders => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::DeleteRootFolderPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteRootFolderPrompt { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::DeleteRootFolder( + self.extract_root_folder_id(), + )); + } + + self.app.pop_navigation_stack(); + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteRootFolderPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::RootFolders => match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ if matches_key!(add, key) => { + self.app.data.readarr_data.add_root_folder_modal = + Some(AddReadarrRootFolderModal::from(&self.app.data.readarr_data)); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + _ => (), + }, + ActiveReadarrBlock::DeleteRootFolderPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::DeleteRootFolder( + self.extract_root_folder_id(), + )); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs new file mode 100644 index 00000000..efd2f7fd --- /dev/null +++ b/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs @@ -0,0 +1,544 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_present; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::root_folders::RootFoldersHandler; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, ROOT_FOLDERS_BLOCKS, + }; + use crate::models::servarr_models::RootFolder; + + 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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + RootFoldersHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::RootFolders, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + } + + #[test] + fn test_delete_root_folder_prompt_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + RootFoldersHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::RootFolders, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::RootFolders.into() + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_root_folders_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(4); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::History.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::History.into()); + } + + #[rstest] + fn test_root_folders_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(4); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Indexers.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + RootFoldersHandler::new( + key, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + RootFoldersHandler::new( + key, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_popped; + use crate::network::readarr_network::ReadarrEvent; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_delete_root_folder_prompt_confirm_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.data.readarr_data.root_folders.select_index(Some(1)); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::DeleteRootFolder(2) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + } + + #[test] + fn test_delete_root_folder_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + } + + #[test] + fn test_delete_root_folder_prompt_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteRootFolderPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + } + + mod test_handle_esc { + use super::*; + use crate::assert_navigation_popped; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_delete_root_folder_prompt_block_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + RootFoldersHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + RootFoldersHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::RootFolders, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_popped; + use crate::network::readarr_network::ReadarrEvent; + + #[test] + fn test_root_folder_add() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + assert_modal_present!(app.data.readarr_data.add_root_folder_modal); + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddRootFolderNameInput + ); + } + + #[test] + fn test_root_folder_add_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::RootFolders.into() + ); + assert_none!(app.data.readarr_data.add_root_folder_modal); + } + + #[test] + fn test_refresh_root_folders_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::RootFolders.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_root_folders_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::RootFolders.into() + ); + assert!(!app.should_refresh); + } + + #[test] + fn test_delete_root_folder_prompt_confirm() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.data.readarr_data.root_folders.select_index(Some(1)); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::DeleteRootFolder(2) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + } + } + + #[test] + fn test_root_folders_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ROOT_FOLDERS_BLOCKS.contains(&active_readarr_block) + || ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) + { + assert!( + RootFoldersHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the RootFoldersHandler" + ); + } else { + assert!( + !RootFoldersHandler::accepts(active_readarr_block), + "{active_readarr_block} is accepted by the RootFoldersHandler" + ); + } + }) + } + + #[rstest] + fn test_root_folders_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_root_folder_id() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.data.readarr_data.root_folders.select_index(Some(1)); + + let root_folder_id = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .extract_root_folder_id(); + + assert_eq!(root_folder_id, 2); + } + + #[test] + fn test_root_folders_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = true; + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + let handler = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_root_folders_handler_not_ready_when_root_folders_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = false; + + let handler = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = false; + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + let handler = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ); + + assert!(handler.is_ready()); + } + + fn root_folders_vec() -> Vec { + vec![ + RootFolder { + id: 1, + path: "/nfs/books".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/audiobooks".to_owned(), + accessible: false, + free_space: 1024, + unmapped_folders: None, + }, + ] + } +} From 5e1f3bb7e38900e2f016eb6beec4d10747b01c3a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 4 Sep 2026 12:22:35 -0600 Subject: [PATCH 095/170] feat(readarr): add the add root folder handler --- .../readarr_handlers/readarr_handler_tests.rs | 1 + .../root_folders/add_root_folder_handler.rs | 534 +++++ .../add_root_folder_handler_tests.rs | 1750 +++++++++++++++++ .../readarr_handlers/root_folders/mod.rs | 20 +- .../root_folders_handler_tests.rs | 30 + 5 files changed, 2332 insertions(+), 3 deletions(-) create mode 100644 src/handlers/readarr_handlers/root_folders/add_root_folder_handler.rs create mode 100644 src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index 063581e8..819b66f4 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -235,6 +235,7 @@ mod tests { fn test_delegates_root_folders_blocks_to_root_folders_handler( #[values( ActiveReadarrBlock::RootFolders, + ActiveReadarrBlock::AddRootFolderPrompt, ActiveReadarrBlock::DeleteRootFolderPrompt )] active_readarr_block: ActiveReadarrBlock, diff --git a/src/handlers/readarr_handlers/root_folders/add_root_folder_handler.rs b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler.rs new file mode 100644 index 00000000..745d01aa --- /dev/null +++ b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler.rs @@ -0,0 +1,534 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::readarr_models::AddReadarrRootFolderBody; +use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, +}; +use crate::models::{Route, Scrollable}; +use crate::network::readarr_network::ReadarrEvent; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; + +#[cfg(test)] +#[path = "add_root_folder_handler_tests.rs"] +mod add_root_folder_handler_tests; + +pub(super) struct AddRootFolderHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl AddRootFolderHandler<'_, '_> { + fn build_add_root_folder_body(&mut self) -> AddReadarrRootFolderBody { + let add_root_folder_modal = self + .app + .data + .readarr_data + .add_root_folder_modal + .take() + .expect("AddReadarrRootFolderModal is None"); + + let tags = add_root_folder_modal.tags.text.clone(); + + let AddReadarrRootFolderModal { + name, + path, + monitor_list, + monitor_new_items_list, + quality_profile_list, + metadata_profile_list, + .. + } = add_root_folder_modal; + + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *self + .app + .data + .readarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + let metadata_profile = metadata_profile_list.current_selection(); + let metadata_profile_id = *self + .app + .data + .readarr_data + .metadata_profile_map + .iter() + .filter(|(_, value)| *value == metadata_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + AddReadarrRootFolderBody { + name: name.text, + path: path.text, + default_quality_profile_id: quality_profile_id, + default_metadata_profile_id: metadata_profile_id, + default_monitor_option: *monitor_list.current_selection(), + default_new_item_monitor_option: *monitor_new_items_list.current_selection(), + default_tags: Vec::new(), + tag_input_string: Some(tags), + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AddRootFolderHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + ADD_ROOT_FOLDER_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> AddRootFolderHandler<'a, 'b> { + AddRootFolderHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.readarr_data.add_root_folder_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_up(), + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_up(), + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_up(), + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_up(), + ActiveReadarrBlock::AddRootFolderPrompt => self.app.data.readarr_data.selected_block.up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_down(), + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_down(), + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_down(), + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_down(), + ActiveReadarrBlock::AddRootFolderPrompt => self.app.data.readarr_data.selected_block.down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_top(), + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_to_top(), + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_top(), + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_top(), + ActiveReadarrBlock::AddRootFolderNameInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .name + .scroll_home(), + ActiveReadarrBlock::AddRootFolderPathInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .path + .scroll_home(), + ActiveReadarrBlock::AddRootFolderTagsInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .tags + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddRootFolderNameInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .name + .reset_offset(), + ActiveReadarrBlock::AddRootFolderPathInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .path + .reset_offset(), + ActiveReadarrBlock::AddRootFolderTagsInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .tags + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderPrompt => handle_prompt_toggle(self.app, self.key), + ActiveReadarrBlock::AddRootFolderNameInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .name + ) + } + ActiveReadarrBlock::AddRootFolderPathInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveReadarrBlock::AddRootFolderTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .tags + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderPrompt => { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::AddRootFolderConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::AddRootFolder( + self.build_add_root_folder_body(), + )); + self.app.should_refresh = true; + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AddRootFolderSelectMonitor + | ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems + | ActiveReadarrBlock::AddRootFolderSelectQualityProfile + | ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => { + self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ) + } + ActiveReadarrBlock::AddRootFolderNameInput + | ActiveReadarrBlock::AddRootFolderPathInput + | ActiveReadarrBlock::AddRootFolderTagsInput => { + self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ); + self.app.ignore_special_keys_for_textbox_input = true; + } + _ => (), + } + } + ActiveReadarrBlock::AddRootFolderSelectMonitor + | ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems + | ActiveReadarrBlock::AddRootFolderSelectQualityProfile + | ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self.app.pop_navigation_stack(), + ActiveReadarrBlock::AddRootFolderNameInput + | ActiveReadarrBlock::AddRootFolderPathInput + | ActiveReadarrBlock::AddRootFolderTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderNameInput + | ActiveReadarrBlock::AddRootFolderPathInput + | ActiveReadarrBlock::AddRootFolderTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::AddRootFolderPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.add_root_folder_modal = None; + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::AddRootFolderSelectMonitor + | ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems + | ActiveReadarrBlock::AddRootFolderSelectQualityProfile + | ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderNameInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .name + ) + } + ActiveReadarrBlock::AddRootFolderPathInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveReadarrBlock::AddRootFolderTagsInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .tags + ) + } + ActiveReadarrBlock::AddRootFolderPrompt + if self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::AddRootFolderConfirmPrompt + && matches_key!(confirm, key) => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::AddRootFolder( + self.build_add_root_folder_body(), + )); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs new file mode 100644 index 00000000..be9c5ada --- /dev/null +++ b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs @@ -0,0 +1,1750 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::root_folders::add_root_folder_handler::AddRootFolderHandler; + use crate::models::readarr_models::{AddReadarrRootFolderBody, MonitorType, NewItemMonitorType}; + use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, + }; + use crate::network::readarr_network::ReadarrEvent; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_add_root_folder_select_monitor_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_type_vec = Vec::from_iter(MonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_type_vec.len()).rev() { + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[i] + ); + } + } else { + for i in 0..monitor_type_vec.len() { + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[(i + 1) % monitor_type_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_root_folder_select_monitor_new_items_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(monitor_type_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_type_vec.len()).rev() { + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_type_vec[i] + ); + } + } else { + for i in 0..monitor_type_vec.len() { + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_type_vec[(i + 1) % monitor_type_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_root_folder_select_quality_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["EPUB".to_owned(), "MOBI".to_owned()]); + + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "MOBI" + ); + + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "EPUB" + ); + } + + #[rstest] + fn test_add_root_folder_select_metadata_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec!["Standard".to_owned(), "Comprehensive".to_owned()]); + + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Comprehensive" + ); + + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Standard" + ); + } + + #[rstest] + fn test_add_root_folder_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + AddRootFolderHandler::new(key, &mut app, ActiveReadarrBlock::AddRootFolderPrompt, None) + .handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddRootFolderNameInput + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddRootFolderSelectMonitor + ); + } + } + + #[rstest] + fn test_add_root_folder_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + AddRootFolderHandler::new(key, &mut app, ActiveReadarrBlock::AddRootFolderPrompt, None) + .handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddRootFolderPathInput + ); + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_eq; + use std::sync::atomic::Ordering; + use strum::IntoEnumIterator; + + use super::*; + + #[test] + fn test_add_root_folder_select_monitor_home_end() { + let monitor_type_vec = Vec::from_iter(MonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[monitor_type_vec.len() - 1] + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[0] + ); + } + + #[test] + fn test_add_root_folder_select_monitor_new_items_home_end() { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(monitor_type_vec.clone()); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_type_vec[monitor_type_vec.len() - 1] + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_type_vec[0] + ); + } + + #[test] + fn test_add_root_folder_select_quality_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec![ + "EPUB".to_owned(), + "MOBI".to_owned(), + "AZW3".to_owned(), + ]); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "AZW3" + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "EPUB" + ); + } + + #[test] + fn test_add_root_folder_select_metadata_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec![ + "Standard".to_owned(), + "Comprehensive".to_owned(), + "Minimal".to_owned(), + ]); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Minimal" + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Standard" + ); + } + + #[test] + fn test_add_root_folder_name_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + name: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_root_folder_path_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + path: "Testing".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 7 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_root_folder_tags_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + tags: "Testing tags".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 12 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + use std::sync::atomic::Ordering; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + AddRootFolderHandler::new(key, &mut app, ActiveReadarrBlock::AddRootFolderPrompt, None) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + AddRootFolderHandler::new(key, &mut app, ActiveReadarrBlock::AddRootFolderPrompt, None) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_add_root_folder_name_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + name: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_root_folder_path_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + path: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_root_folder_tags_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + tags: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_add_root_folder_name_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + name: "Test Name".into(), + ..AddReadarrRootFolderModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderNameInput.into()); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text, + "Test Name" + ); + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + + #[test] + fn test_add_root_folder_path_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + path: "/nfs/Test Path".into(), + ..AddReadarrRootFolderModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPathInput.into()); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text, + "/nfs/Test Path" + ); + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + + #[test] + fn test_add_root_folder_tags_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + tags: "usenet, testing".into(), + ..AddReadarrRootFolderModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderTagsInput.into()); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text, + "usenet, testing" + ); + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + + #[test] + fn test_add_root_folder_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_ROOT_FOLDER_SELECTION_BLOCKS.len() - 1); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_add_root_folder_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(populated_add_root_folder_modal()); + seed_distinct_profile_maps(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_ROOT_FOLDER_SELECTION_BLOCKS.len() - 1); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::AddRootFolder(expected_add_root_folder_body())) + ); + assert_modal_absent!(app.data.readarr_data.add_root_folder_modal); + assert!(app.should_refresh); + } + + #[test] + fn test_add_root_folder_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddRootFolderPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + } + + #[rstest] + #[case(ActiveReadarrBlock::AddRootFolderNameInput, 0)] + #[case(ActiveReadarrBlock::AddRootFolderPathInput, 1)] + #[case(ActiveReadarrBlock::AddRootFolderSelectMonitor, 2)] + #[case(ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, 3)] + #[case(ActiveReadarrBlock::AddRootFolderSelectQualityProfile, 4)] + #[case(ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, 5)] + #[case(ActiveReadarrBlock::AddRootFolderTagsInput, 6)] + fn test_add_root_folder_prompt_selected_block_submit( + #[case] selected_block: ActiveReadarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack( + ( + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .into(), + ); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .handle(); + + assert_navigation_pushed!( + app, + (selected_block, Some(ActiveReadarrBlock::RootFolders)).into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + + if selected_block == ActiveReadarrBlock::AddRootFolderNameInput + || selected_block == ActiveReadarrBlock::AddRootFolderPathInput + || selected_block == ActiveReadarrBlock::AddRootFolderTagsInput + { + assert!(app.ignore_special_keys_for_textbox_input); + } + } + + #[rstest] + fn test_add_root_folder_prompt_selected_block_submit_no_op_when_not_ready( + #[values(0, 1, 2, 3, 4, 5, 6)] y_index: usize, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack( + ( + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .into(), + ); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ( + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.ignore_special_keys_for_textbox_input); + } + + #[rstest] + fn test_add_root_folder_prompt_selecting_preferences_blocks_submit( + #[values( + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + ActiveReadarrBlock::AddRootFolderNameInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + active_readarr_block, + Some(ActiveReadarrBlock::RootFolders), + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + + if active_readarr_block == ActiveReadarrBlock::AddRootFolderNameInput + || active_readarr_block == ActiveReadarrBlock::AddRootFolderPathInput + || active_readarr_block == ActiveReadarrBlock::AddRootFolderTagsInput + { + assert!(!app.ignore_special_keys_for_textbox_input); + } + } + } + + mod test_handle_esc { + use rstest::rstest; + + use crate::assert_navigation_popped; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_add_root_folder_input_esc( + #[values( + ActiveReadarrBlock::AddRootFolderTagsInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderNameInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.ignore_special_keys_for_textbox_input = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AddRootFolderHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + + #[test] + fn test_add_root_folder_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.data.readarr_data.prompt_confirm = true; + + AddRootFolderHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_modal_absent!(app.data.readarr_data.add_root_folder_modal); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_add_root_folder_esc( + #[values( + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile + )] + active_readarr_block: ActiveReadarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AddRootFolderHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; + + use super::*; + + #[test] + fn test_add_root_folder_name_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + name: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text, + "Tes" + ); + } + + #[test] + fn test_add_root_folder_path_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + path: "Path".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text, + "Pat" + ); + } + + #[test] + fn test_add_root_folder_tags_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + tags: "Tags".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text, + "Tag" + ); + } + + #[test] + fn test_add_root_folder_name_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + AddRootFolderHandler::new( + Key::Char('n'), + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text, + "n" + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text + ); + } + + #[test] + fn test_add_root_folder_path_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + AddRootFolderHandler::new( + Key::Char('p'), + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text, + "p" + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text + ); + } + + #[test] + fn test_add_root_folder_tags_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + AddRootFolderHandler::new( + Key::Char('t'), + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text, + "t" + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text + ); + } + + #[test] + fn test_add_root_folder_confirm_prompt_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(populated_add_root_folder_modal()); + seed_distinct_profile_maps(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_ROOT_FOLDER_SELECTION_BLOCKS.len() - 1); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::AddRootFolder(expected_add_root_folder_body())) + ); + assert_modal_absent!(app.data.readarr_data.add_root_folder_modal); + assert!(app.should_refresh); + } + } + + #[test] + fn test_add_root_folder_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) { + assert!( + AddRootFolderHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the AddRootFolderHandler" + ); + } else { + assert!( + !AddRootFolderHandler::accepts(active_readarr_block), + "{active_readarr_block} is accepted by the AddRootFolderHandler" + ); + } + }); + } + + #[rstest] + fn test_add_root_folder_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_add_root_folder_body() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(populated_add_root_folder_modal()); + seed_distinct_profile_maps(&mut app); + + let add_root_folder_body = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .build_add_root_folder_body(); + + assert_eq!(add_root_folder_body, expected_add_root_folder_body()); + assert_modal_absent!(app.data.readarr_data.add_root_folder_modal); + } + + #[test] + fn test_build_add_root_folder_body_uses_the_selected_profiles() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(populated_add_root_folder_modal()); + seed_distinct_profile_maps(&mut app); + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + let add_root_folder_body = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .build_add_root_folder_body(); + + assert_eq!(add_root_folder_body.default_quality_profile_id, 2222); + assert_eq!(add_root_folder_body.default_metadata_profile_id, 4444); + } + + #[test] + fn test_add_root_folder_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + let handler = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_add_root_folder_handler_is_not_ready_when_add_root_folder_modal_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = false; + + let handler = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_add_root_folder_handler_is_ready_when_add_root_folder_modal_is_some() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = false; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + let handler = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ); + + assert!(handler.is_ready()); + } + + fn populated_add_root_folder_modal() -> AddReadarrRootFolderModal { + let mut add_root_folder_modal = AddReadarrRootFolderModal { + name: "Test Name".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + tags: "usenet, testing".to_owned().into(), + ..AddReadarrRootFolderModal::default() + }; + add_root_folder_modal + .quality_profile_list + .set_items(vec!["EPUB".to_owned(), "MOBI".to_owned()]); + add_root_folder_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned(), "Comprehensive".to_owned()]); + add_root_folder_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + + add_root_folder_modal + } + + fn seed_distinct_profile_maps(app: &mut App<'_>) { + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "EPUB".to_owned()), (2222, "MOBI".to_owned())]); + app.data.readarr_data.metadata_profile_map = BiMap::from_iter([ + (3333, "Standard".to_owned()), + (4444, "Comprehensive".to_owned()), + ]); + } + + fn expected_add_root_folder_body() -> AddReadarrRootFolderBody { + AddReadarrRootFolderBody { + name: "Test Name".to_owned(), + path: "/nfs/Test Path".to_owned(), + default_quality_profile_id: 1111, + default_metadata_profile_id: 3333, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: Vec::new(), + tag_input_string: Some("usenet, testing".to_owned()), + } + } +} diff --git a/src/handlers/readarr_handlers/root_folders/mod.rs b/src/handlers/readarr_handlers/root_folders/mod.rs index 58f98703..fc0cc20c 100644 --- a/src/handlers/readarr_handlers/root_folders/mod.rs +++ b/src/handlers/readarr_handlers/root_folders/mod.rs @@ -1,3 +1,5 @@ +use add_root_folder_handler::AddRootFolderHandler; + use crate::app::App; use crate::event::Key; use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; @@ -11,6 +13,8 @@ use crate::models::servarr_data::readarr::readarr_data::{ use crate::models::{BlockSelectionState, Route}; use crate::network::readarr_network::ReadarrEvent; +mod add_root_folder_handler; + #[cfg(test)] #[path = "root_folders_handler_tests.rs"] mod root_folders_handler_tests; @@ -19,7 +23,7 @@ pub(super) struct RootFoldersHandler<'a, 'b> { key: Key, app: &'a mut App<'b>, active_readarr_block: ActiveReadarrBlock, - _context: Option, + context: Option, } impl RootFoldersHandler<'_, '_> { @@ -39,6 +43,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for RootFoldersHandler< let root_folders_table_handling_config = TableHandlingConfig::new(ActiveReadarrBlock::RootFolders.into()); + if AddRootFolderHandler::accepts(self.active_readarr_block) { + return AddRootFolderHandler::new( + self.key, + self.app, + self.active_readarr_block, + self.context, + ) + .handle(); + } + if !handle_table( self, |app| &mut app.data.readarr_data.root_folders, @@ -60,13 +74,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for RootFoldersHandler< key: Key, app: &'a mut App<'b>, active_block: ActiveReadarrBlock, - _context: Option, + context: Option, ) -> RootFoldersHandler<'a, 'b> { RootFoldersHandler { key, app, active_readarr_block: active_block, - _context, + context, } } diff --git a/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs index efd2f7fd..0d220853 100644 --- a/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs @@ -143,10 +143,40 @@ mod tests { use super::*; use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; use crate::network::readarr_network::ReadarrEvent; const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + #[test] + fn test_add_root_folder_prompt_submit_passes_the_context_to_the_add_root_folder_handler() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + + RootFoldersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .handle(); + + assert_navigation_pushed!( + app, + ( + ActiveReadarrBlock::AddRootFolderNameInput, + Some(ActiveReadarrBlock::RootFolders) + ) + .into() + ); + } + #[test] fn test_delete_root_folder_prompt_confirm_submit() { let mut app = App::test_default(); From 58fe7d79ae852fc360803ecd77ac5218aae017f2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 09:15:37 -0600 Subject: [PATCH 096/170] feat(readarr): add the root folders UI Renders the root folders table, the delete prompt and the add root folder modal. The modal conveys its current step purely through styling, which rendered output discards, so the step highlights are computed by a separate function whose branches are asserted directly. --- src/ui/readarr_ui/mod.rs | 3 + .../root_folders/add_root_folder_ui.rs | 274 ++++++++++++++++++ .../root_folders/add_root_folder_ui_tests.rs | 231 +++++++++++++++ src/ui/readarr_ui/root_folders/mod.rs | 102 +++++++ .../root_folders/root_folders_ui_tests.rs | 190 ++++++++++++ ...oot_folder_AddRootFolderConfirmPrompt.snap | 48 +++ ...dd_root_folder_AddRootFolderNameInput.snap | 48 +++ ...dd_root_folder_AddRootFolderPathInput.snap | 48 +++ ...__add_root_folder_AddRootFolderPrompt.snap | 48 +++ ...er_AddRootFolderSelectMetadataProfile.snap | 48 +++ ...oot_folder_AddRootFolderSelectMonitor.snap | 48 +++ ...er_AddRootFolderSelectMonitorNewItems.snap | 48 +++ ...der_AddRootFolderSelectQualityProfile.snap | 48 +++ ...dd_root_folder_AddRootFolderTagsInput.snap | 48 +++ ..._folder_step_0_AddRootFolderNameInput.snap | 48 +++ ..._folder_step_1_AddRootFolderPathInput.snap | 48 +++ ...der_step_2_AddRootFolderSelectMonitor.snap | 48 +++ ..._3_AddRootFolderSelectMonitorNewItems.snap | 48 +++ ...p_4_AddRootFolderSelectQualityProfile.snap | 48 +++ ..._5_AddRootFolderSelectMetadataProfile.snap | 48 +++ ..._folder_step_6_AddRootFolderTagsInput.snap | 48 +++ ...der_step_7_AddRootFolderConfirmPrompt.snap | 48 +++ ...hot_tests__AddRootFolderConfirmPrompt.snap | 48 +++ ...napshot_tests__AddRootFolderNameInput.snap | 48 +++ ...napshot_tests__AddRootFolderPathInput.snap | 48 +++ ...__snapshot_tests__AddRootFolderPrompt.snap | 48 +++ ...s__AddRootFolderSelectMetadataProfile.snap | 48 +++ ...hot_tests__AddRootFolderSelectMonitor.snap | 48 +++ ...s__AddRootFolderSelectMonitorNewItems.snap | 48 +++ ...ts__AddRootFolderSelectQualityProfile.snap | 48 +++ ...napshot_tests__AddRootFolderTagsInput.snap | 48 +++ ...napshot_tests__DeleteRootFolderPrompt.snap | 38 +++ ...s__tests__snapshot_tests__RootFolders.snap | 7 + ...t_root_folders_DeleteRootFolderPrompt.snap | 38 +++ ...ts__distinct_root_folders_RootFolders.snap | 8 + ...lete_prompt_for_non_default_selection.snap | 38 +++ ...folders_ui_renders_empty_root_folders.snap | 5 + ...ests__root_folders_ui_renders_loading.snap | 8 + ...pshot_tests__readarr_tabs_RootFolders.snap | 6 +- 39 files changed, 2193 insertions(+), 3 deletions(-) create mode 100644 src/ui/readarr_ui/root_folders/add_root_folder_ui.rs create mode 100644 src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs create mode 100644 src/ui/readarr_ui/root_folders/mod.rs create mode 100644 src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderNameInput.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPathInput.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPrompt.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMetadataProfile.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitor.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitorNewItems.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectQualityProfile.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderTagsInput.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_0_AddRootFolderNameInput.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_1_AddRootFolderPathInput.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_2_AddRootFolderSelectMonitor.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_3_AddRootFolderSelectMonitorNewItems.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_4_AddRootFolderSelectQualityProfile.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_5_AddRootFolderSelectMetadataProfile.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_6_AddRootFolderTagsInput.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_7_AddRootFolderConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderNameInput.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPathInput.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPrompt.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMetadataProfile.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitor.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitorNewItems.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectQualityProfile.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderTagsInput.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__DeleteRootFolderPrompt.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__RootFolders.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_DeleteRootFolderPrompt.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_RootFolders.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_delete_prompt_for_non_default_selection.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_empty_root_folders.snap create mode 100644 src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_loading.snap diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs index 8539533f..b4463fcd 100644 --- a/src/ui/readarr_ui/mod.rs +++ b/src/ui/readarr_ui/mod.rs @@ -24,6 +24,7 @@ use super::{ }; use crate::ui::readarr_ui::blocklist::BlocklistUi; use crate::ui::readarr_ui::downloads::DownloadsUi; +use crate::ui::readarr_ui::root_folders::RootFoldersUi; use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; use crate::{ app::App, @@ -42,6 +43,7 @@ mod downloads; mod history; mod library; mod readarr_ui_utils; +mod root_folders; #[cfg(test)] #[path = "readarr_ui_tests.rs"] @@ -63,6 +65,7 @@ impl DrawUi for ReadarrUi { _ 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/readarr_ui/root_folders/add_root_folder_ui.rs b/src/ui/readarr_ui/root_folders/add_root_folder_ui.rs new file mode 100644 index 00000000..c67d3298 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/add_root_folder_ui.rs @@ -0,0 +1,274 @@ +use std::sync::atomic::Ordering; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::prelude::Layout; +use ratatui::widgets::ListItem; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, +}; +use crate::render_selectable_input_box; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "add_root_folder_ui_tests.rs"] +mod add_root_folder_ui_tests; + +pub(super) struct AddRootFolderUi; + +impl DrawUi for AddRootFolderUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let draw_add_root_folder_prompt = + |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| { + draw_add_root_folder_confirmation_prompt(f, app, prompt_area); + + match active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => { + draw_add_root_folder_select_monitor_popup(f, app); + } + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => { + draw_add_root_folder_select_monitor_new_items_popup(f, app); + } + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => { + draw_add_root_folder_select_quality_profile_popup(f, app); + } + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => { + draw_add_root_folder_select_metadata_profile_popup(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_add_root_folder_prompt, Size::Long); + } + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct AddRootFolderPromptHighlights { + name: bool, + path: bool, + monitor: bool, + monitor_new_items: bool, + quality_profile: bool, + metadata_profile: bool, + tags: bool, + confirm: bool, +} + +fn add_root_folder_prompt_highlights( + selected_block: ActiveReadarrBlock, +) -> AddRootFolderPromptHighlights { + AddRootFolderPromptHighlights { + name: selected_block == ActiveReadarrBlock::AddRootFolderNameInput, + path: selected_block == ActiveReadarrBlock::AddRootFolderPathInput, + monitor: selected_block == ActiveReadarrBlock::AddRootFolderSelectMonitor, + monitor_new_items: selected_block == ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + quality_profile: selected_block == ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + metadata_profile: selected_block == ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + tags: selected_block == ActiveReadarrBlock::AddRootFolderTagsInput, + confirm: selected_block == ActiveReadarrBlock::AddRootFolderConfirmPrompt, + } +} + +fn draw_add_root_folder_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let title = "Add Root Folder"; + f.render_widget(title_block_centered(title), area); + + let yes_no_value = app.data.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = add_root_folder_prompt_highlights(selected_block); + let AddReadarrRootFolderModal { + name, + path, + monitor_list, + monitor_new_items_list, + quality_profile_list, + metadata_profile_list, + tags, + } = app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .expect("add_root_folder_modal must exist in this context"); + let selected_monitor = monitor_list.current_selection(); + let selected_monitor_new_items = monitor_new_items_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_metadata_profile = metadata_profile_list.current_selection(); + + let [ + _, + name_area, + path_area, + monitor_area, + monitor_new_items_area, + quality_profile_area, + metadata_profile_area, + tags_area, + _, + buttons_area, + ] = Layout::vertical([ + Constraint::Fill(1), + 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), + ]) + .margin(1) + .areas(area); + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let monitor_drop_down_button = Button::default() + .title(selected_monitor.to_display_str()) + .label("Monitor") + .icon("▼") + .selected(highlights.monitor); + let monitor_new_items_drop_down_button = Button::default() + .title(selected_monitor_new_items.to_display_str()) + .label("Monitor New Books") + .icon("▼") + .selected(highlights.monitor_new_items); + let quality_profile_drop_down_button = Button::default() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(highlights.quality_profile); + let metadata_profile_drop_down_button = Button::default() + .title(selected_metadata_profile) + .label("Metadata Profile") + .icon("▼") + .selected(highlights.metadata_profile); + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let name_input_box = InputBox::new(&name.text) + .offset(name.offset.load(Ordering::SeqCst)) + .label("Name") + .highlighted(highlights.name) + .selected(active_readarr_block == ActiveReadarrBlock::AddRootFolderNameInput); + let path_input_box = InputBox::new(&path.text) + .offset(path.offset.load(Ordering::SeqCst)) + .label("Path") + .highlighted(highlights.path) + .selected(active_readarr_block == ActiveReadarrBlock::AddRootFolderPathInput); + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::AddRootFolderTagsInput); + + match active_readarr_block { + ActiveReadarrBlock::AddRootFolderNameInput => name_input_box.show_cursor(f, name_area), + ActiveReadarrBlock::AddRootFolderPathInput => path_input_box.show_cursor(f, path_area), + ActiveReadarrBlock::AddRootFolderTagsInput => tags_input_box.show_cursor(f, tags_area), + _ => (), + } + + render_selectable_input_box!(name_input_box, f, name_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::default() + .title("Save") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + f.render_widget(monitor_drop_down_button, monitor_area); + f.render_widget(monitor_new_items_drop_down_button, monitor_new_items_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(metadata_profile_drop_down_button, metadata_profile_area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); +} + +fn draw_add_root_folder_select_monitor_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context") + .monitor_list, + |monitor_type| ListItem::new(monitor_type.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_root_folder_select_monitor_new_items_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_new_items_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context") + .monitor_new_items_list, + |monitor_type| ListItem::new(monitor_type.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_new_items_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_root_folder_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context") + .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_root_folder_select_metadata_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let metadata_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context") + .metadata_profile_list, + |metadata_profile| ListItem::new(metadata_profile.clone()), + ); + let popup = Popup::new(metadata_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} diff --git a/src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs b/src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs new file mode 100644 index 00000000..2a9666a0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs @@ -0,0 +1,231 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ADD_ROOT_FOLDER_SELECTION_BLOCKS, ActiveReadarrBlock, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::root_folders::add_root_folder_ui::{ + AddRootFolderPromptHighlights, AddRootFolderUi, add_root_folder_prompt_highlights, + }; + + #[test] + fn test_add_root_folder_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) { + assert!(AddRootFolderUi::accepts(active_readarr_block.into())); + } else { + assert!(!AddRootFolderUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::AddRootFolderNameInput, + AddRootFolderPromptHighlights { + name: true, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderPathInput, + AddRootFolderPromptHighlights { + name: false, + path: true, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderSelectMonitor, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: true, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: true, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: true, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: true, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderTagsInput, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderConfirmPrompt, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: true, + } + )] + fn test_add_root_folder_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: AddRootFolderPromptHighlights, + ) { + let highlights = add_root_folder_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_add_root_folder_prompt_highlights_covers_every_selection_step() { + for step in ADD_ROOT_FOLDER_SELECTION_BLOCKS { + let highlights = add_root_folder_prompt_highlights(step[0]); + + assert_ne!( + highlights, + add_root_folder_prompt_highlights(ActiveReadarrBlock::RootFolders) + ); + } + } + + mod snapshot_tests { + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_profile_lists(app: &mut App<'_>) { + let add_root_folder_modal = app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context"); + add_root_folder_modal + .quality_profile_list + .set_items(vec!["eBook".to_owned()]); + add_root_folder_modal + .metadata_profile_list + .set_items(vec!["Author Only".to_owned()]); + } + + #[rstest] + fn test_add_root_folder_ui_renders( + #[values( + ActiveReadarrBlock::AddRootFolderPrompt, + ActiveReadarrBlock::AddRootFolderConfirmPrompt, + ActiveReadarrBlock::AddRootFolderNameInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + ActiveReadarrBlock::AddRootFolderTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddRootFolderUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("add_root_folder_{active_readarr_block}"), output); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::AddRootFolderNameInput)] + #[case(1, ActiveReadarrBlock::AddRootFolderPathInput)] + #[case(2, ActiveReadarrBlock::AddRootFolderSelectMonitor)] + #[case(3, ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems)] + #[case(4, ActiveReadarrBlock::AddRootFolderSelectQualityProfile)] + #[case(5, ActiveReadarrBlock::AddRootFolderSelectMetadataProfile)] + #[case(6, ActiveReadarrBlock::AddRootFolderTagsInput)] + #[case(7, ActiveReadarrBlock::AddRootFolderConfirmPrompt)] + fn test_add_root_folder_modal_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddRootFolderUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("add_root_folder_step_{step}_{selected_readarr_block}"), + output + ); + } + } +} diff --git a/src/ui/readarr_ui/root_folders/mod.rs b/src/ui/readarr_ui/root_folders/mod.rs new file mode 100644 index 00000000..c99469e0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/mod.rs @@ -0,0 +1,102 @@ +use add_root_folder_ui::AddRootFolderUi; +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, ROOT_FOLDERS_BLOCKS, +}; +use crate::models::servarr_models::RootFolder; +use crate::ui::DrawUi; +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::utils::convert_to_gb; + +mod add_root_folder_ui; + +#[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::Readarr(active_readarr_block, _) = route { + return ROOT_FOLDERS_BLOCKS.contains(&active_readarr_block) + || ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_root_folders(f, app, area); + + if ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) { + AddRootFolderUi::draw(f, app, area); + } else if active_readarr_block == ActiveReadarrBlock::DeleteRootFolderPrompt { + let prompt = format!( + "Do you really want to delete this root folder: \n{}?", + app.data.readarr_data.root_folders.current_selection().path + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Root Folder") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + } + } +} + +fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + 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.readarr_data.root_folders), + root_folders_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .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); +} diff --git a/src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs b/src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs new file mode 100644 index 00000000..791f0279 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs @@ -0,0 +1,190 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, ROOT_FOLDERS_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::root_folders::RootFoldersUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_root_folders_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ROOT_FOLDERS_BLOCKS.contains(&active_readarr_block) + || ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) + { + assert!(RootFoldersUi::accepts(active_readarr_block.into())); + } else { + assert!(!RootFoldersUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use super::*; + use crate::models::BlockSelectionState; + use crate::models::Scrollable; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; + use crate::models::servarr_models::{RootFolder, UnmappedFolder}; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + fn seed_distinct_root_folders(app: &mut App<'_>) { + app.data.readarr_data.root_folders.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs/books".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/audiobooks".to_owned(), + accessible: false, + free_space: 1099511627776, + unmapped_folders: Some(vec![ + UnmappedFolder { + name: "Unsorted Anthologies".to_owned(), + path: "/nfs/audiobooks/unsorted-anthologies".to_owned(), + }, + UnmappedFolder { + name: "Pending Imports".to_owned(), + path: "/nfs/audiobooks/pending-imports".to_owned(), + }, + UnmappedFolder { + name: "Damaged Rips".to_owned(), + path: "/nfs/audiobooks/damaged-rips".to_owned(), + }, + ]), + }, + ]); + } + + fn seed_distinct_profile_lists(app: &mut App<'_>) { + let add_root_folder_modal = app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context"); + add_root_folder_modal + .quality_profile_list + .set_items(vec!["eBook".to_owned()]); + add_root_folder_modal + .metadata_profile_list + .set_items(vec!["Author Only".to_owned()]); + } + + #[test] + fn test_root_folders_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_root_folders_ui_renders_empty_root_folders() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_root_folders_ui_renders_root_folders_tab( + #[values( + ActiveReadarrBlock::RootFolders, + ActiveReadarrBlock::DeleteRootFolderPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(active_readarr_block.to_string(), output); + } + + #[rstest] + fn test_root_folders_ui_renders_distinct_root_folders( + #[values( + ActiveReadarrBlock::RootFolders, + ActiveReadarrBlock::DeleteRootFolderPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + seed_distinct_root_folders(&mut app); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("distinct_root_folders_{active_readarr_block}"), + output + ); + } + + #[test] + fn test_root_folders_ui_renders_delete_prompt_for_non_default_selection() { + let mut app = App::test_default_fully_populated(); + seed_distinct_root_folders(&mut app); + app.data.readarr_data.root_folders.scroll_down(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_root_folders_ui_renders_add_root_folder_popup_over_root_folders_table( + #[values( + ActiveReadarrBlock::AddRootFolderPrompt, + ActiveReadarrBlock::AddRootFolderConfirmPrompt, + ActiveReadarrBlock::AddRootFolderNameInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + ActiveReadarrBlock::AddRootFolderTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(active_readarr_block.to_string(), output); + } + } +} diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderConfirmPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderConfirmPrompt.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderNameInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderNameInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderNameInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPathInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPathInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPrompt.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMetadataProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMetadataProfile.snap new file mode 100644 index 00000000..4bae78d6 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │Author Only │──────────────────────────────╮ │ + │ │ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitor.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitor.snap new file mode 100644 index 00000000..2d9649ae --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │All Books │──────────────────────────────╮ │ + │ │Future Books │ ▼ │ │ + │ │Missing Books │──────────────────────────────╯ │ + │ │Existing Books │──────────────────────────────╮ │ + │ Monito│First Book │ ▼ │ │ + │ │Latest Book │──────────────────────────────╯ │ + │ │None │──────────────────────────────╮ │ + │ Qual│Unknown │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitorNewItems.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitorNewItems.snap new file mode 100644 index 00000000..65e7198a --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │All Books │──────────────────────────────╮ │ + │ │No New Books │ ▼ │ │ + │ │New Books │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectQualityProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectQualityProfile.snap new file mode 100644 index 00000000..f3dc8c7d --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │eBook │──────────────────────────────╮ │ + │ │ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderTagsInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderTagsInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_0_AddRootFolderNameInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_0_AddRootFolderNameInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_0_AddRootFolderNameInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_1_AddRootFolderPathInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_1_AddRootFolderPathInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_1_AddRootFolderPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_2_AddRootFolderSelectMonitor.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_2_AddRootFolderSelectMonitor.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_2_AddRootFolderSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_3_AddRootFolderSelectMonitorNewItems.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_3_AddRootFolderSelectMonitorNewItems.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_3_AddRootFolderSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_4_AddRootFolderSelectQualityProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_4_AddRootFolderSelectQualityProfile.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_4_AddRootFolderSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_5_AddRootFolderSelectMetadataProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_5_AddRootFolderSelectMetadataProfile.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_5_AddRootFolderSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_6_AddRootFolderTagsInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_6_AddRootFolderTagsInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_6_AddRootFolderTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_7_AddRootFolderConfirmPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_7_AddRootFolderConfirmPrompt.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_7_AddRootFolderConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderConfirmPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderConfirmPrompt.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderNameInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderNameInput.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderNameInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPathInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPathInput.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPrompt.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMetadataProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMetadataProfile.snap new file mode 100644 index 00000000..60259596 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │Author Only │──────────────────────────────╮ │ + │ │ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitor.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitor.snap new file mode 100644 index 00000000..9d8d5dbd --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │All Books │──────────────────────────────╮ │ + │ │Future Books │ ▼ │ │ + │ │Missing Books │──────────────────────────────╯ │ + │ │Existing Books │──────────────────────────────╮ │ + │ Monito│First Book │ ▼ │ │ + │ │Latest Book │──────────────────────────────╯ │ + │ │None │──────────────────────────────╮ │ + │ Qual│Unknown │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitorNewItems.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitorNewItems.snap new file mode 100644 index 00000000..30be8e86 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │All Books │──────────────────────────────╮ │ + │ │No New Books │ ▼ │ │ + │ │New Books │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectQualityProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectQualityProfile.snap new file mode 100644 index 00000000..da678d02 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │eBook │──────────────────────────────╮ │ + │ │ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderTagsInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderTagsInput.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__DeleteRootFolderPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__DeleteRootFolderPrompt.snap new file mode 100644 index 00000000..1ab60315 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__DeleteRootFolderPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + + + + + + + + + + + ╭────────────────── Delete Root Folder ───────────────────╮ + │ Do you really want to delete this root folder: │ + │ /nfs? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__RootFolders.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__RootFolders.snap new file mode 100644 index 00000000..861bb5ca --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__RootFolders.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_DeleteRootFolderPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_DeleteRootFolderPrompt.snap new file mode 100644 index 00000000..aa610de4 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_DeleteRootFolderPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs/books 204800.00 GB 0 + /nfs/audiobooks 1024.00 GB 3 + + + + + + + + + + + + + ╭────────────────── Delete Root Folder ───────────────────╮ + │ Do you really want to delete this root folder: │ + │ /nfs/books? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_RootFolders.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_RootFolders.snap new file mode 100644 index 00000000..5949277a --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_RootFolders.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs/books 204800.00 GB 0 + /nfs/audiobooks 1024.00 GB 3 diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_delete_prompt_for_non_default_selection.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_delete_prompt_for_non_default_selection.snap new file mode 100644 index 00000000..4e7aec89 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_delete_prompt_for_non_default_selection.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders + /nfs/books 204800.00 GB 0 +=> /nfs/audiobooks 1024.00 GB 3 + + + + + + + + + + + + + ╭────────────────── Delete Root Folder ───────────────────╮ + │ Do you really want to delete this root folder: │ + │ /nfs/audiobooks? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_empty_root_folders.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_empty_root_folders.snap new file mode 100644 index 00000000..84395021 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_empty_root_folders.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_loading.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_loading.snap new file mode 100644 index 00000000..894b060a --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap index d3f01721..408c386b 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap @@ -4,9 +4,9 @@ expression: output --- ╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ -│ │ -│ │ -│ │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Path Free Space Unmapped Folders │ +│=> /nfs 204800.00 GB 0 │ │ │ │ │ │ │ From 24c5dedd0cddb93437a8528d4a8fbabd58334b18 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 09:33:26 -0600 Subject: [PATCH 097/170] feat(readarr): add the system handlers SystemHandler owns the System tab: tab movement, the refresh key, and the five keys that open the queued events, logs, tasks, and updates views. It delegates every system detail block to SystemDetailsHandler, which scrolls the log, task, queued event, and update views and drives the start task confirmation prompt. Both land together because SystemHandler::accepts covers the system detail blocks that only SystemDetailsHandler implements, so landing the parent alone would accept those blocks while doing nothing with them. --- src/handlers/readarr_handlers/mod.rs | 5 + .../readarr_handlers/readarr_handler_tests.rs | 19 + src/handlers/readarr_handlers/system/mod.rs | 135 ++ .../system/system_details_handler.rs | 211 +++ .../system/system_details_handler_tests.rs | 1151 +++++++++++++++++ .../system/system_handler_tests.rs | 496 +++++++ 6 files changed, 2017 insertions(+) create mode 100644 src/handlers/readarr_handlers/system/mod.rs create mode 100644 src/handlers/readarr_handlers/system/system_details_handler.rs create mode 100644 src/handlers/readarr_handlers/system/system_details_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/system/system_handler_tests.rs diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 50c47a3c..759f172d 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -4,6 +4,7 @@ use crate::handlers::readarr_handlers::downloads::DownloadsHandler; use crate::handlers::readarr_handlers::history::HistoryHandler; use crate::handlers::readarr_handlers::library::LibraryHandler; use crate::handlers::readarr_handlers::root_folders::RootFoldersHandler; +use crate::handlers::readarr_handlers::system::SystemHandler; use crate::models::Route; use crate::{ app::App, event::Key, matches_key, @@ -15,6 +16,7 @@ mod downloads; mod history; mod library; mod root_folders; +mod system; #[cfg(test)] #[path = "readarr_handler_tests.rs"] @@ -46,6 +48,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, RootFoldersHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); } + _ if SystemHandler::accepts(self.active_readarr_block) => { + SystemHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } _ => self.handle_key_event(), } } diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index 819b66f4..00504374 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -246,4 +246,23 @@ mod tests { active_readarr_block ); } + + #[rstest] + fn test_delegates_system_blocks_to_system_handler( + #[values( + ActiveReadarrBlock::System, + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::System, + active_readarr_block + ); + } } diff --git a/src/handlers/readarr_handlers/system/mod.rs b/src/handlers/readarr_handlers/system/mod.rs new file mode 100644 index 00000000..caffa715 --- /dev/null +++ b/src/handlers/readarr_handlers/system/mod.rs @@ -0,0 +1,135 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::readarr_handlers::system::system_details_handler::SystemDetailsHandler; +use crate::handlers::{KeyEventHandler, handle_clear_errors}; +use crate::matches_key; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::models::{Route, 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_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for SystemHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_readarr_block { + _ if SystemDetailsHandler::accepts(self.active_readarr_block) => { + SystemDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle() + } + _ => self.handle_key_event(), + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + SystemDetailsHandler::accepts(active_block) || active_block == ActiveReadarrBlock::System + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> SystemHandler<'a, 'b> { + SystemHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + && !self.app.data.readarr_data.logs.is_empty() + && !self.app.data.readarr_data.queued_events.is_empty() + && !self.app.data.readarr_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_readarr_block == ActiveReadarrBlock::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_readarr_block == ActiveReadarrBlock::System { + let key = self.key; + match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ if matches_key!(events, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemQueuedEvents.into()); + } + _ if matches_key!(logs, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemLogs.into()); + self + .app + .data + .readarr_data + .log_details + .set_items(self.app.data.readarr_data.logs.items.to_vec()); + self.app.data.readarr_data.log_details.scroll_to_bottom(); + } + _ if matches_key!(tasks, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemUpdates.into()); + } + _ => (), + } + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/system/system_details_handler.rs b/src/handlers/readarr_handlers/system/system_details_handler.rs new file mode 100644 index 00000000..432a9bf1 --- /dev/null +++ b/src/handlers/readarr_handlers/system/system_details_handler.rs @@ -0,0 +1,211 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::readarr_models::ReadarrTaskName; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, +}; +use crate::models::stateful_list::StatefulList; +use crate::models::{Route, Scrollable}; +use crate::network::readarr_network::ReadarrEvent; + +#[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_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl SystemDetailsHandler<'_, '_> { + fn extract_task_name(&self) -> ReadarrTaskName { + self + .app + .data + .readarr_data + .tasks + .current_selection() + .task_name + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for SystemDetailsHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + SYSTEM_DETAILS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> SystemDetailsHandler<'a, 'b> { + SystemDetailsHandler { + key, + app, + active_readarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + && (!self.app.data.readarr_data.log_details.is_empty() + || !self.app.data.readarr_data.tasks.is_empty() + || !self.app.data.readarr_data.updates.is_empty()) + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => self.app.data.readarr_data.log_details.scroll_up(), + ActiveReadarrBlock::SystemTasks => self.app.data.readarr_data.tasks.scroll_up(), + ActiveReadarrBlock::SystemUpdates => self.app.data.readarr_data.updates.scroll_up(), + ActiveReadarrBlock::SystemQueuedEvents => { + self.app.data.readarr_data.queued_events.scroll_up() + } + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => self.app.data.readarr_data.log_details.scroll_down(), + ActiveReadarrBlock::SystemTasks => self.app.data.readarr_data.tasks.scroll_down(), + ActiveReadarrBlock::SystemUpdates => self.app.data.readarr_data.updates.scroll_down(), + ActiveReadarrBlock::SystemQueuedEvents => { + self.app.data.readarr_data.queued_events.scroll_down() + } + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => self.app.data.readarr_data.log_details.scroll_to_top(), + ActiveReadarrBlock::SystemTasks => self.app.data.readarr_data.tasks.scroll_to_top(), + ActiveReadarrBlock::SystemUpdates => self.app.data.readarr_data.updates.scroll_to_top(), + ActiveReadarrBlock::SystemQueuedEvents => { + self.app.data.readarr_data.queued_events.scroll_to_top() + } + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => self.app.data.readarr_data.log_details.scroll_to_bottom(), + ActiveReadarrBlock::SystemTasks => self.app.data.readarr_data.tasks.scroll_to_bottom(), + ActiveReadarrBlock::SystemUpdates => self.app.data.readarr_data.updates.scroll_to_bottom(), + ActiveReadarrBlock::SystemQueuedEvents => { + self.app.data.readarr_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_readarr_block { + ActiveReadarrBlock::SystemLogs => match self.key { + _ if matches_key!(left, key) => { + self + .app + .data + .readarr_data + .log_details + .items + .iter() + .for_each(|log| log.scroll_right()); + } + _ if matches_key!(right, key) => { + self + .app + .data + .readarr_data + .log_details + .items + .iter() + .for_each(|log| log.scroll_left()); + } + _ => (), + }, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemTasks => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + } + ActiveReadarrBlock::SystemTaskStartConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::StartTask(self.extract_task_name())); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => { + self.app.data.readarr_data.log_details = StatefulList::default(); + self.app.pop_navigation_stack() + } + ActiveReadarrBlock::SystemQueuedEvents + | ActiveReadarrBlock::SystemTasks + | ActiveReadarrBlock::SystemUpdates => self.app.pop_navigation_stack(), + ActiveReadarrBlock::SystemTaskStartConfirmPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + if SYSTEM_DETAILS_BLOCKS.contains(&self.active_readarr_block) && matches_key!(refresh, self.key) + { + self.app.should_refresh = true; + } + + if self.active_readarr_block == ActiveReadarrBlock::SystemTaskStartConfirmPrompt + && matches_key!(confirm, self.key) + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::StartTask(self.extract_task_name())); + self.app.pop_navigation_stack(); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/system/system_details_handler_tests.rs b/src/handlers/readarr_handlers/system/system_details_handler_tests.rs new file mode 100644 index 00000000..27af3c9d --- /dev/null +++ b/src/handlers/readarr_handlers/system/system_details_handler_tests.rs @@ -0,0 +1,1151 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::system::system_details_handler::SystemDetailsHandler; + use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + use crate::models::{HorizontallyScrollableText, ScrollableText}; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::HorizontallyScrollableText; + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + + use super::*; + + test_iterable_scroll!( + test_log_details_scroll, + SystemDetailsHandler, + readarr_data, + log_details, + simple_stateful_iterable_vec!(HorizontallyScrollableText, String, text), + ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app + .data + .readarr_data + .log_details + .set_items(simple_stateful_iterable_vec!( + HorizontallyScrollableText, + String, + text + )); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemLogs, None).handle(); + + assert_str_eq!( + app.data.readarr_data.log_details.current_selection().text, + "Test 1" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemLogs, None).handle(); + + assert_str_eq!( + app.data.readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Rss sync task" + ); + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + } + + #[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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.tasks.set_items(tasks_vec()); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + } + + #[rstest] + fn test_queued_events_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event two" + ); + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + } + + #[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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + } + + #[test] + fn test_system_updates_scroll() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 1); + } + + #[test] + fn test_system_updates_scroll_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + } + } + + mod test_handle_home_end { + use crate::models::HorizontallyScrollableText; + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + use pretty_assertions::assert_eq; + + test_iterable_home_and_end!( + test_log_details_home_end, + SystemDetailsHandler, + readarr_data, + log_details, + extended_stateful_iterable_vec!(HorizontallyScrollableText, String, text), + ActiveReadarrBlock::SystemLogs, + None, + text + ); + + #[test] + fn test_log_details_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app + .data + .readarr_data + .log_details + .set_items(extended_stateful_iterable_vec!( + HorizontallyScrollableText, + String, + text + )); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemLogs, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.log_details.current_selection().text, + "Test 1" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemLogs, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.log_details.current_selection().text, + "Test 1" + ); + } + + #[test] + fn test_tasks_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.tasks.set_items(extended_tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(extended_queued_events_vec()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Housekeeping task" + ); + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + } + + #[test] + fn test_tasks_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.tasks.set_items(extended_tasks_vec()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + } + + #[test] + fn test_queued_events_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.tasks.set_items(extended_tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(extended_queued_events_vec()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event three" + ); + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + } + + #[test] + fn test_queued_events_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.tasks.set_items(extended_tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(extended_queued_events_vec()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + } + + #[test] + fn test_system_updates_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 1); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + } + + #[test] + fn test_system_updates_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_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_readarr_block = ActiveReadarrBlock::SystemLogs; + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app + .data + .readarr_data + .log_details + .set_items(vec!["t1".into(), "t22".into()]); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "t1"); + assert_eq!( + app.data.readarr_data.log_details.items[1].to_string(), + "t22" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "1"); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), "22"); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), ""); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), "2"); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), ""); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), ""); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), ""); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), ""); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "1"); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), "2"); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "t1"); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), "22"); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "t1"); + assert_eq!( + app.data.readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + SystemDetailsHandler::new( + key, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + SystemDetailsHandler::new( + key, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use crate::assert_navigation_popped; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_system_tasks_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::SystemTasks, None) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + } + + #[test] + fn test_system_tasks_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::SystemTasks, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::SystemTasks.into() + ); + } + + #[test] + fn test_system_tasks_start_task_prompt_confirm_submit() { + let mut app = App::test_default(); + app.data.readarr_data.updates = updates_text(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.tasks.set_items(tasks_vec()); + app.data.readarr_data.tasks.select_index(Some(1)); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + + SystemDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::StartTask(ReadarrTaskName::RssSync) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::SystemTasks.into()); + } + + #[test] + fn test_system_tasks_start_task_prompt_decline_submit() { + let mut app = App::test_default(); + app.data.readarr_data.updates = updates_text(); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + + SystemDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::SystemTasks.into()); + } + } + + mod test_handle_esc { + use crate::models::HorizontallyScrollableText; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_esc_system_logs(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app + .data + .readarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::from("test")]); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemLogs.into()); + app + .data + .readarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::default()]); + + SystemDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::SystemLogs, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + assert_is_empty!(app.data.readarr_data.log_details.items); + } + + #[rstest] + fn test_esc_system_tasks(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + + SystemDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + } + + #[rstest] + fn test_esc_system_queued_events(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemQueuedEvents.into()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + SystemDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + } + + #[rstest] + fn test_esc_system_updates(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemUpdates.into()); + + SystemDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::SystemUpdates, None) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + } + + #[test] + fn test_system_tasks_start_task_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + SystemDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::SystemTasks.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + + #[rstest] + fn test_refresh_key( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + app.push_navigation_stack(active_readarr_block.into()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, active_readarr_block.into()); + assert!(app.should_refresh); + } + + #[rstest] + fn test_refresh_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.updates = updates_text(); + app.push_navigation_stack(active_readarr_block.into()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_system_tasks_start_task_prompt_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app.data.readarr_data.tasks.select_index(Some(1)); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::StartTask(ReadarrTaskName::RssSync)) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::SystemTasks.into()); + } + } + + #[test] + fn test_system_details_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if SYSTEM_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!(SystemDetailsHandler::accepts(active_readarr_block)); + } else { + assert!(!SystemDetailsHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_system_details_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_task_name_reads_the_selected_task() { + let mut app = App::test_default(); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app.data.readarr_data.tasks.select_index(Some(1)); + + let task_name = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .extract_task_name(); + + assert_eq!(task_name, ReadarrTaskName::RssSync); + } + + #[test] + fn test_extract_task_name_reads_the_first_task_when_nothing_is_selected() { + let mut app = App::test_default(); + app.data.readarr_data.tasks.set_items(tasks_vec()); + + let task_name = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .extract_task_name(); + + assert_eq!(task_name, ReadarrTaskName::Backup); + } + + #[test] + fn test_system_details_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.updates = updates_text(); + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_system_details_handler_not_ready_when_log_details_and_updates_and_tasks_are_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app + .data + .readarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::default()]); + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_system_details_handler_ready_when_not_loading_and_tasks_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.tasks.set_items(tasks_vec()); + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.updates = updates_text(); + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ); + + assert!(handler.is_ready()); + } + + fn updates_text() -> ScrollableText { + ScrollableText::with_string("update note one\nupdate note two".to_owned()) + } + + fn tasks_vec() -> Vec { + vec![ + ReadarrTask { + name: "Backup task".to_owned(), + task_name: ReadarrTaskName::Backup, + ..ReadarrTask::default() + }, + ReadarrTask { + name: "Rss sync task".to_owned(), + task_name: ReadarrTaskName::RssSync, + ..ReadarrTask::default() + }, + ] + } + + fn extended_tasks_vec() -> Vec { + let mut tasks = tasks_vec(); + tasks.push(ReadarrTask { + name: "Housekeeping task".to_owned(), + task_name: ReadarrTaskName::Housekeeping, + ..ReadarrTask::default() + }); + + tasks + } + + fn queued_events_vec() -> Vec { + vec![ + QueueEvent { + name: "queued event one".to_owned(), + ..QueueEvent::default() + }, + QueueEvent { + name: "queued event two".to_owned(), + ..QueueEvent::default() + }, + ] + } + + fn extended_queued_events_vec() -> Vec { + let mut queued_events = queued_events_vec(); + queued_events.push(QueueEvent { + name: "queued event three".to_owned(), + ..QueueEvent::default() + }); + + queued_events + } +} diff --git a/src/handlers/readarr_handlers/system/system_handler_tests.rs b/src/handlers/readarr_handlers/system/system_handler_tests.rs new file mode 100644 index 00000000..4ffe1b0f --- /dev/null +++ b/src/handlers/readarr_handlers/system/system_handler_tests.rs @@ -0,0 +1,496 @@ +#[cfg(test)] +mod tests { + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::system::SystemHandler; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + use crate::test_handler_delegation; + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_system_tab_left(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = is_loading; + app.data.readarr_data.main_tabs.set_index(6); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Indexers.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Indexers.into()); + } + + #[rstest] + fn test_system_tab_right(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = is_loading; + app.data.readarr_data.main_tabs.set_index(6); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Authors.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_esc { + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_default_esc(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + SystemHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::System, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use super::*; + + #[test] + fn test_update_system_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemUpdates.into()); + } + + #[test] + fn test_update_system_key_no_op_if_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + } + + #[test] + fn test_queued_events_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.events.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemQueuedEvents.into()); + } + + #[test] + fn test_queued_events_key_no_op_if_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.events.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + } + + #[test] + fn test_refresh_system_key() { + let mut app = App::test_default(); + seed_system_data(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::System.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_system_key_no_op_if_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + seed_system_data(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_logs_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.logs.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemLogs.into()); + assert_eq!( + app.data.readarr_data.log_details.items, + app.data.readarr_data.logs.items + ); + assert_str_eq!( + app.data.readarr_data.log_details.current_selection().text, + "log line gamma" + ); + } + + #[test] + fn test_logs_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.logs.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + assert_is_empty!(app.data.readarr_data.log_details); + } + + #[test] + fn test_tasks_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.tasks.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemTasks.into()); + } + + #[test] + fn test_tasks_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.tasks.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + } + + #[test] + fn test_unmapped_key_is_a_no_op() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + assert!(!app.should_refresh); + assert_is_empty!(app.data.readarr_data.log_details); + } + } + + #[rstest] + fn test_delegates_system_details_blocks_to_system_details_handler( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + SystemHandler, + ActiveReadarrBlock::System, + active_readarr_block + ); + } + + #[test] + fn test_system_handler_accepts() { + let mut system_blocks = vec![ActiveReadarrBlock::System]; + system_blocks.extend(SYSTEM_DETAILS_BLOCKS); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if system_blocks.contains(&active_readarr_block) { + assert!(SystemHandler::accepts(active_readarr_block)); + } else { + assert!(!SystemHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_system_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_system_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + seed_system_data(&mut app); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_not_ready_when_logs_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_not_ready_when_tasks_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.logs.set_items(logs_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_not_ready_when_queued_events_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.logs.set_items(logs_vec()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + seed_system_data(&mut app); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ); + + assert!(system_handler.is_ready()); + } + + fn seed_system_data(app: &mut App<'_>) { + app.data.readarr_data.logs.set_items(logs_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + } + + fn logs_vec() -> Vec { + vec![ + HorizontallyScrollableText::from("log line alpha"), + HorizontallyScrollableText::from("log line beta"), + HorizontallyScrollableText::from("log line gamma"), + ] + } + + fn queued_events_vec() -> Vec { + vec![ + QueueEvent { + name: "queued event one".to_owned(), + ..QueueEvent::default() + }, + QueueEvent { + name: "queued event two".to_owned(), + ..QueueEvent::default() + }, + ] + } + + fn tasks_vec() -> Vec { + vec![ + ReadarrTask { + name: "Backup task".to_owned(), + task_name: ReadarrTaskName::Backup, + ..ReadarrTask::default() + }, + ReadarrTask { + name: "Rss sync task".to_owned(), + task_name: ReadarrTaskName::RssSync, + ..ReadarrTask::default() + }, + ] + } +} From 70441259a05e25d80d8a92975b4f8c363277a60f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 09:51:55 -0600 Subject: [PATCH 098/170] feat(readarr): add the system UI The system tab renders the tasks table, the queued events table and the log list, and overlays the log details, tasks, queued events, task start confirmation and updates popups. SystemUi and SystemDetailsUi ship together because SystemUi::accepts covers every block that only SystemDetailsUi draws. Landing them apart would advertise five blocks that nothing renders. Log level extraction is pulled into a named function so it can be unit tested directly: the level only ever reaches the output as a colour, and the rendered snapshots are plain strings that discard it. --- src/ui/readarr_ui/mod.rs | 3 + ...__snapshot_tests__readarr_tabs_System.snap | 94 +++---- src/ui/readarr_ui/system/mod.rs | 203 +++++++++++++ ...sts__snapshot_tests__empty_SystemLogs.snap | 48 ++++ ...pshot_tests__empty_SystemQueuedEvents.snap | 44 +++ ...ts__snapshot_tests__empty_SystemTasks.snap | 48 ++++ ...s__snapshot_tests__loading_SystemLogs.snap | 48 ++++ ...hot_tests__loading_SystemQueuedEvents.snap | 44 +++ ...__snapshot_tests__loading_SystemTasks.snap | 48 ++++ ...snapshot_tests__loading_SystemUpdates.snap | 48 ++++ ...sts__snapshot_tests__popup_SystemLogs.snap | 48 ++++ ...pshot_tests__popup_SystemQueuedEvents.snap | 44 +++ ...s__popup_SystemTaskStartConfirmPrompt.snap | 48 ++++ ...ts__snapshot_tests__popup_SystemTasks.snap | 48 ++++ ...__snapshot_tests__popup_SystemUpdates.snap | 48 ++++ ...onfirm_prompt_names_the_selected_task.snap | 48 ++++ ...p_is_loading_when_updates_are_present.snap | 48 ++++ ...s_ui_updates_popup_loading_when_empty.snap | 48 ++++ ...sts__popups_over_system_ui_SystemLogs.snap | 54 ++++ ...ups_over_system_ui_SystemQueuedEvents.snap | 54 ++++ ...ystem_ui_SystemTaskStartConfirmPrompt.snap | 54 ++++ ...ts__popups_over_system_ui_SystemTasks.snap | 54 ++++ ...__popups_over_system_ui_SystemUpdates.snap | 54 ++++ ...tests__system_ui_renders_logs_loading.snap | 54 ++++ ...t_tests__system_ui_renders_system_tab.snap | 54 ++++ ...s__system_ui_renders_system_tab_empty.snap | 54 ++++ ..._system_ui_renders_system_tab_loading.snap | 54 ++++ ...rs_system_tab_task_and_events_loading.snap | 54 ++++ src/ui/readarr_ui/system/system_details_ui.rs | 147 ++++++++++ .../system/system_details_ui_tests.rs | 242 ++++++++++++++++ src/ui/readarr_ui/system/system_ui_tests.rs | 266 ++++++++++++++++++ 31 files changed, 2156 insertions(+), 47 deletions(-) create mode 100644 src/ui/readarr_ui/system/mod.rs create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemLogs.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemQueuedEvents.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemTasks.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemLogs.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemQueuedEvents.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemTasks.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemUpdates.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemLogs.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemQueuedEvents.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTaskStartConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTasks.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemUpdates.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_task_start_confirm_prompt_names_the_selected_task.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_loading_when_empty.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemLogs.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemQueuedEvents.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTaskStartConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTasks.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemUpdates.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_logs_loading.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_empty.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_loading.snap create mode 100644 src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_task_and_events_loading.snap create mode 100644 src/ui/readarr_ui/system/system_details_ui.rs create mode 100644 src/ui/readarr_ui/system/system_details_ui_tests.rs create mode 100644 src/ui/readarr_ui/system/system_ui_tests.rs diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs index b4463fcd..a1e68c53 100644 --- a/src/ui/readarr_ui/mod.rs +++ b/src/ui/readarr_ui/mod.rs @@ -25,6 +25,7 @@ use super::{ use crate::ui::readarr_ui::blocklist::BlocklistUi; use crate::ui::readarr_ui::downloads::DownloadsUi; use crate::ui::readarr_ui::root_folders::RootFoldersUi; +use crate::ui::readarr_ui::system::SystemUi; use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; use crate::{ app::App, @@ -44,6 +45,7 @@ mod history; mod library; mod readarr_ui_utils; mod root_folders; +mod system; #[cfg(test)] #[path = "readarr_ui_tests.rs"] @@ -66,6 +68,7 @@ impl DrawUi for ReadarrUi { _ 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 SystemUi::accepts(route) => SystemUi::draw(f, app, content_area), _ => (), } } diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap index d3f01721..8ee87793 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap @@ -4,51 +4,51 @@ expression: output --- ╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ +│╭ Tasks ───────────────────────────────────────────────────────────────────────╮╭ Queued Events ──────────────────────────────────────────────────────────────╮│ +││Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration ││ +││Backup 1 hour now 59 minutes ││manual completed Refresh Monitored 4 minutes ago 4 minutes a 00:03:03 ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +│╰────────────────────────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────────────────────╯│ +│╭ Logs ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ +││2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|No list items to process ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/mod.rs b/src/ui/readarr_ui/system/mod.rs new file mode 100644 index 00000000..2c4f3059 --- /dev/null +++ b/src/ui/readarr_ui/system/mod.rs @@ -0,0 +1,203 @@ +use std::ops::Sub; + +#[cfg(not(test))] +use chrono::Utc; +use ratatui::text::{Span, Text}; +use ratatui::widgets::{Cell, Row}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + widgets::ListItem, +}; + +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::ReadarrTask; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::models::servarr_models::QueueEvent; +use crate::ui::DrawUi; +use crate::ui::readarr_ui::system::system_details_ui::SystemDetailsUi; +use crate::ui::styles::{ManagarrStyle, default_style}; +#[cfg(test)] +use crate::ui::ui_test_utils::test_utils::Utc; +use crate::ui::utils::{convert_to_minutes_hours_days, style_log_list_item, title_block}; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::selectable_list::SelectableList; + +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 { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + SystemDetailsUi::accepts(route) || active_readarr_block == ActiveReadarrBlock::System + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + draw_system_ui_layout(f, app, area); + + if SystemDetailsUi::accepts(route) { + SystemDetailsUi::draw(f, app, area); + } + } +} + +fn draw_system_ui_layout(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let [activities_area, logs_area] = + Layout::vertical([Constraint::Ratio(1, 2), Constraint::Ratio(1, 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); +} + +fn draw_tasks(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let tasks_row_mapping = |task: &ReadarrTask| { + 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.readarr_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 let Some(date_time) = event.started { + let started = convert_to_minutes_hours_days(Utc::now().sub(date_time).num_minutes()); + + if started != "now" { + format!("{started} ago") + } else { + started + } + } else { + String::new() + }; + + let duration = if let Some(dur) = &event.duration { + &dur[..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.readarr_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.readarr_data.logs.items.is_empty() { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + return; + } + + let logs_box = SelectableList::new(&mut app.data.readarr_data.logs, |log| { + let log_line = log.to_string(); + let level = extract_log_level(&log_line); + + style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) + }) + .block(block) + .highlight_style(default_style()); + + f.render_widget(logs_box, area); +} + +pub(super) fn extract_log_level(log_line: &str) -> String { + log_line.split('|').collect::>()[1].to_string() +} + +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: &ReadarrTask) -> 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/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemLogs.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemLogs.snap new file mode 100644 index 00000000..77cc4918 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemLogs.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Log Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemQueuedEvents.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemQueuedEvents.snap new file mode 100644 index 00000000..06f90228 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemQueuedEvents.snap @@ -0,0 +1,44 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + + + + + ╭ Queued Events ────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemTasks.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemTasks.snap new file mode 100644 index 00000000..42783c04 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemTasks.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemLogs.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemLogs.snap new file mode 100644 index 00000000..aed87ded --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemLogs.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Log Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemQueuedEvents.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemQueuedEvents.snap new file mode 100644 index 00000000..bd8e76f4 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemQueuedEvents.snap @@ -0,0 +1,44 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + + + + + ╭ Queued Events ────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemTasks.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemTasks.snap new file mode 100644 index 00000000..1f14d555 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemTasks.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemUpdates.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemUpdates.snap new file mode 100644 index 00000000..944d9739 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemUpdates.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemLogs.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemLogs.snap new file mode 100644 index 00000000..ca13cf1d --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemLogs.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Log Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha │ + │2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta │ + │2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemQueuedEvents.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemQueuedEvents.snap new file mode 100644 index 00000000..7fa24517 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemQueuedEvents.snap @@ -0,0 +1,44 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + + + + + ╭ Queued Events ────────────────────────────────────────────────────────────────────────────────╮ + │Trigger Status Name Queued Started Duration │ + │manual completed queued event one 1 hour ago 30 minutes ag 00:29:14 │ + │scheduled started queued event two 15 minutes ago 4 minutes ago 01:02:03 │ + │external queued queued event three 1 minute ago │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTaskStartConfirmPrompt.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTaskStartConfirmPrompt.snap new file mode 100644 index 00000000..4cd6d8c4 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTaskStartConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name Interval Last Execution Next Execution │ + │=> Backup task 2 hours 30 minutes ago 1 hour │ + │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ + │ Housekeeping task 1 day 4 hours ago 18 hours │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭────────────────────── Start Task ───────────────────────╮ │ + │ │ Do you want to manually start this task: Backup task? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTasks.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTasks.snap new file mode 100644 index 00000000..2950c7dd --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTasks.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name Interval Last Execution Next Execution │ + │=> Backup task 2 hours 30 minutes ago 1 hour │ + │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ + │ Housekeeping task 1 day 4 hours ago 18 hours │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemUpdates.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemUpdates.snap new file mode 100644 index 00000000..0a4cb97d --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemUpdates.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │The latest version of Sonarr is already installed │ + │ │ + │4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) │ + │--------------------------------------------------------------------------------------------------------------------------│ + │New: │ + │ * Cool new thing │ + │Fixed: │ + │ * Some bugs killed │ + │ │ + │ │ + │3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) │ + │--------------------------------------------------------------------------------------------------------------------------│ + │New: │ + │ * Cool new thing (old) │ + │ * Other cool new thing (old) │ + │ │ + │ │ + │2.1.0 - 2023-04-15 02:02:53 UTC │ + │--------------------------------------------------------------------------------------------------------------------------│ + │Fixed: │ + │ * Killed bug 1 │ + │ * Fixed bug 2 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_task_start_confirm_prompt_names_the_selected_task.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_task_start_confirm_prompt_names_the_selected_task.snap new file mode 100644 index 00000000..bece945f --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_task_start_confirm_prompt_names_the_selected_task.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name Interval Last Execution Next Execution │ + │ Backup task 2 hours 30 minutes ago 1 hour │ + │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ + │=> Housekeeping task 1 day 4 hours ago 18 hours │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭────────────────────── Start Task ───────────────────────╮ │ + │ │Do you want to manually start this task: Housekeeping task?│ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap new file mode 100644 index 00000000..944d9739 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_loading_when_empty.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_loading_when_empty.snap new file mode 100644 index 00000000..944d9739 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_loading_when_empty.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemLogs.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemLogs.snap new file mode 100644 index 00000000..1587e814 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemLogs.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ╭ Log Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha │ │ +│ │2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta │ │ +│ │2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +╰───────────────────│ │────────────────────╯ +╭ Logs ───────────│ │────────────────────╮ +│2025-12-16 16:40:59│ │ │ +│2025-12-16 16:41:12│ │ │ +│2025-12-16 16:41:30│ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemQueuedEvents.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemQueuedEvents.snap new file mode 100644 index 00000000..45db2d79 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemQueuedEvents.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ╭ Queued Events ────────────────────────────────────────────────────────────────────────────────╮ │ +│ │Trigger Status Name Queued Started Duration │ │ +│ │manual completed queued event one 1 hour ago 30 minutes ag 00:29:14 │ │ +│ │scheduled started queued event two 15 minutes ago 4 minutes ago 01:02:03 │ │ +│ │external queued queued event three 1 minute ago │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +╰────────────────────────────────│ │────────────────────────────────╯ +╭ Logs ────────────────────────│ │────────────────────────────────╮ +│2025-12-16 16:40:59 UTC|INFO|Imp│ │ │ +│2025-12-16 16:41:12 UTC|WARN|Boo│ │ │ +│2025-12-16 16:41:30 UTC|FATAL|Co│ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTaskStartConfirmPrompt.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTaskStartConfirmPrompt.snap new file mode 100644 index 00000000..610f58ba --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTaskStartConfirmPrompt.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Name Interval Last Execution Next Execution │ │ +│ │=> Backup task 2 hours 30 minutes ago 1 hour │ │ +│ │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ │ +│ │ Housekeeping task 1 day 4 hours ago 18 hours │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ ╭────────────────────── Start Task ───────────────────────╮ │ │ +│ │ │ Do you want to manually start this task: Backup task? │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +╰───────────────────│ │ │ │────────────────────╯ +╭ Logs ───────────│ │ │ │────────────────────╮ +│2025-12-16 16:40:59│ │ │ │ │ +│2025-12-16 16:41:12│ │ │ │ │ +│2025-12-16 16:41:30│ │ │ │ │ +│ │ │ │ │ │ +│ │ │╭────────────────────────────╮╭───────────────────────────╮│ │ │ +│ │ ││ Yes ││ No ││ │ │ +│ │ │╰────────────────────────────╯╰───────────────────────────╯│ │ │ +│ │ ╰───────────────────────────────────────────────────────────╯ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTasks.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTasks.snap new file mode 100644 index 00000000..6d027532 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTasks.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Name Interval Last Execution Next Execution │ │ +│ │=> Backup task 2 hours 30 minutes ago 1 hour │ │ +│ │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ │ +│ │ Housekeeping task 1 day 4 hours ago 18 hours │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +╰───────────────────│ │────────────────────╯ +╭ Logs ───────────│ │────────────────────╮ +│2025-12-16 16:40:59│ │ │ +│2025-12-16 16:41:12│ │ │ +│2025-12-16 16:41:30│ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemUpdates.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemUpdates.snap new file mode 100644 index 00000000..c14b945c --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemUpdates.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │The latest version of Sonarr is already installed │ │ +│ │ │ │ +│ │4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) │ │ +│ │--------------------------------------------------------------------------------------------------------------------------│ │ +│ │New: │ │ +│ │ * Cool new thing │ │ +│ │Fixed: │ │ +│ │ * Some bugs killed │ │ +│ │ │ │ +│ │ │ │ +│ │3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) │ │ +│ │--------------------------------------------------------------------------------------------------------------------------│ │ +│ │New: │ │ +│ │ * Cool new thing (old) │ │ +│ │ * Other cool new thing (old) │ │ +│ │ │ │ +│ │ │ │ +╰───────────────────│2.1.0 - 2023-04-15 02:02:53 UTC │────────────────────╯ +╭ Logs ───────────│--------------------------------------------------------------------------------------------------------------------------│────────────────────╮ +│2025-12-16 16:40:59│Fixed: │ │ +│2025-12-16 16:41:12│ * Killed bug 1 │ │ +│2025-12-16 16:41:30│ * Fixed bug 2 │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_logs_loading.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_logs_loading.snap new file mode 100644 index 00000000..dcab9378 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_logs_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│ ││ │ +│ ││ │ +│ Loading ... ││ Loading ... │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ Loading ... │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab.snap new file mode 100644 index 00000000..262148f0 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha │ +│2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta │ +│2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_empty.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_empty.snap new file mode 100644 index 00000000..c7a4efc5 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_empty.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_loading.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_loading.snap new file mode 100644 index 00000000..dcab9378 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│ ││ │ +│ ││ │ +│ Loading ... ││ Loading ... │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ Loading ... │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_task_and_events_loading.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_task_and_events_loading.snap new file mode 100644 index 00000000..867ae2a1 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_task_and_events_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│ ││ │ +│ ││ │ +│ Loading ... ││ Loading ... │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha │ +│2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta │ +│2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/system_details_ui.rs b/src/ui/readarr_ui/system/system_details_ui.rs new file mode 100644 index 00000000..7473ae91 --- /dev/null +++ b/src/ui/readarr_ui/system/system_details_ui.rs @@ -0,0 +1,147 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::text::{Span, Text}; +use ratatui::widgets::{Cell, ListItem, Paragraph, Row}; + +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::ReadarrTask; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, +}; +use crate::ui::readarr_ui::system::{ + TASK_TABLE_CONSTRAINTS, TASK_TABLE_HEADERS, draw_queued_events, extract_log_level, + extract_task_props, +}; +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::{DrawUi, draw_popup}; + +#[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 { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + SYSTEM_DETAILS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + match active_readarr_block { + ActiveReadarrBlock::SystemLogs => { + draw_logs_popup(f, app); + } + ActiveReadarrBlock::SystemTasks | ActiveReadarrBlock::SystemTaskStartConfirmPrompt => { + draw_popup(f, app, draw_tasks_popup, Size::Large) + } + ActiveReadarrBlock::SystemQueuedEvents => { + draw_popup(f, app, draw_queued_events, Size::Medium) + } + ActiveReadarrBlock::SystemUpdates => { + draw_updates_popup(f, app); + } + _ => (), + } + } + } +} + +fn draw_logs_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let block = title_block("Log Details"); + + if app.data.readarr_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).margin(1); + + f.render_widget(popup, f.area()); + return; + } + + let logs_list = SelectableList::new(&mut app.data.readarr_data.log_details, |log| { + let log_line = log.to_string(); + let level = extract_log_level(&log.text); + + 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) + .margin(1); + + f.render_widget(popup, f.area()); +} + +fn draw_tasks_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let tasks_row_mapping = |task: &ReadarrTask| { + 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.readarr_data.tasks), tasks_row_mapping) + .loading(app.is_loading) + .margin(1) + .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::Readarr(ActiveReadarrBlock::SystemTaskStartConfirmPrompt, _) + ) { + let prompt = format!( + "Do you want to manually start this task: {}?", + app.data.readarr_data.tasks.current_selection().name + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Start Task") + .prompt(&prompt) + .yes_no_value(app.data.readarr_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 updates = app.data.readarr_data.updates.get_text(); + let block = title_block("Updates"); + + if !updates.is_empty() && !app.is_loading { + let updates_paragraph = Paragraph::new(Text::from(updates)) + .block(borderless_block()) + .scroll((app.data.readarr_data.updates.offset, 0)); + let popup = Popup::new(updates_paragraph) + .size(Size::Large) + .block(block) + .margin(1); + + 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).margin(1); + + f.render_widget(popup, f.area()); + } +} diff --git a/src/ui/readarr_ui/system/system_details_ui_tests.rs b/src/ui/readarr_ui/system/system_details_ui_tests.rs new file mode 100644 index 00000000..4fa1d960 --- /dev/null +++ b/src/ui/readarr_ui/system/system_details_ui_tests.rs @@ -0,0 +1,242 @@ +#[cfg(test)] +mod tests { + use chrono::DateTime; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + + use crate::ui::DrawUi; + use crate::ui::readarr_ui::system::system_details_ui::SystemDetailsUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_system_details_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if SYSTEM_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!(SystemDetailsUi::accepts(active_readarr_block.into())); + } else { + assert!(!SystemDetailsUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use super::*; + use crate::models::ScrollableText; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + #[rstest] + fn test_system_details_ui_popups( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("popup_{active_readarr_block}"), output); + } + + #[test] + fn test_system_details_ui_task_start_confirm_prompt_names_the_selected_task() { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.data.readarr_data.tasks.select_index(Some(2)); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + assert!(output.contains("Do you want to manually start this task: Housekeeping task?")); + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_details_ui_updates_popup_is_loading_when_updates_are_present() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::SystemUpdates.into()); + + assert!(!app.data.readarr_data.updates.get_text().is_empty()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_system_details_ui_loading( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("loading_{active_readarr_block}"), output); + } + + #[test] + fn test_system_details_ui_updates_popup_loading_when_empty() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::SystemUpdates.into()); + app.data.readarr_data.updates = ScrollableText::with_string("".to_string()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_system_details_ui_popups_empty( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("empty_{active_readarr_block}"), output); + } + } + + fn populate_system_data(app: &mut App<'_>) { + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + app.data.readarr_data.logs.set_items(log_lines_vec()); + app.data.readarr_data.log_details.set_items(log_lines_vec()); + } + + fn tasks_vec() -> Vec { + vec![ + ReadarrTask { + name: "Backup task".to_owned(), + task_name: ReadarrTaskName::Backup, + interval: 120, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:00:00Z").unwrap(), + ), + last_duration: "00:00:00.5111547".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T22:30:00Z").unwrap(), + ), + }, + ReadarrTask { + name: "Rss sync task".to_owned(), + task_name: ReadarrTaskName::RssSync, + interval: 15, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:22:00Z").unwrap(), + ), + last_duration: "00:00:01.2233445".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:33:00Z").unwrap(), + ), + }, + ReadarrTask { + name: "Housekeeping task".to_owned(), + task_name: ReadarrTaskName::Housekeeping, + interval: 1440, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T17:30:00Z").unwrap(), + ), + last_duration: "00:00:09.9887766".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-21T15:30:00Z").unwrap(), + ), + }, + ] + } + + fn queued_events_vec() -> Vec { + vec![ + QueueEvent { + trigger: "manual".to_owned(), + name: "event name one".to_owned(), + command_name: "queued event one".to_owned(), + status: "completed".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T20:30:00Z").unwrap()), + started: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:00:00Z").unwrap(), + )), + ended: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:29:14Z").unwrap(), + )), + duration: Some("00:29:14.1234567".to_owned()), + }, + QueueEvent { + trigger: "scheduled".to_owned(), + name: "event name two".to_owned(), + command_name: "queued event two".to_owned(), + status: "started".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:15:00Z").unwrap()), + started: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:26:00Z").unwrap(), + )), + ended: None, + duration: Some("01:02:03.7654321".to_owned()), + }, + QueueEvent { + trigger: "external".to_owned(), + name: "event name three".to_owned(), + command_name: "queued event three".to_owned(), + status: "queued".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:00Z").unwrap()), + started: None, + ended: None, + duration: None, + }, + ] + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha".into(), + "2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } +} diff --git a/src/ui/readarr_ui/system/system_ui_tests.rs b/src/ui/readarr_ui/system/system_ui_tests.rs new file mode 100644 index 00000000..4716ac80 --- /dev/null +++ b/src/ui/readarr_ui/system/system_ui_tests.rs @@ -0,0 +1,266 @@ +#[cfg(test)] +mod tests { + use chrono::DateTime; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::system::SystemUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_system_ui_accepts() { + let mut system_ui_blocks = Vec::new(); + system_ui_blocks.push(ActiveReadarrBlock::System); + system_ui_blocks.extend(SYSTEM_DETAILS_BLOCKS); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if system_ui_blocks.contains(&active_readarr_block) { + assert!(SystemUi::accepts(active_readarr_block.into())); + } else { + assert!(!SystemUi::accepts(active_readarr_block.into())); + } + }); + } + + mod test_extract_log_level { + use pretty_assertions::assert_str_eq; + + use crate::ui::readarr_ui::system::extract_log_level; + + #[test] + fn test_extract_log_level_returns_the_field_after_the_timestamp() { + let level = extract_log_level("2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|Message"); + + assert_str_eq!(level, "INFO"); + } + + #[test] + fn test_extract_log_level_preserves_case() { + let level = extract_log_level("2025-12-16 16:41:30 UTC|FaTaL|CommandExecutor|Message"); + + assert_str_eq!(level, "FaTaL"); + } + + #[test] + fn test_extract_log_level_ignores_later_fields() { + let level = extract_log_level("timestamp|WARN|logger|message|with|extra|pipes"); + + assert_str_eq!(level, "WARN"); + } + + #[test] + fn test_extract_log_level_returns_an_empty_string_when_the_level_is_absent() { + let level = extract_log_level("2025-12-16 16:41:12 UTC||BookScanService|Message"); + + assert_str_eq!(level, ""); + } + } + + mod snapshot_tests { + use crate::models::stateful_list::StatefulList; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + use super::*; + + #[test] + fn test_system_ui_renders_system_tab_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_ui_renders_logs_loading() { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.logs = StatefulList::default(); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_ui_renders_system_tab_task_and_events_loading() { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_ui_renders_system_tab() { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_ui_renders_system_tab_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_system_details_ui_renders_popups_over_system_ui( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("popups_over_system_ui_{active_readarr_block}"), + output + ); + } + } + + fn populate_system_data(app: &mut App<'_>) { + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + app.data.readarr_data.logs.set_items(log_lines_vec()); + app.data.readarr_data.log_details.set_items(log_lines_vec()); + } + + fn tasks_vec() -> Vec { + vec![ + ReadarrTask { + name: "Backup task".to_owned(), + task_name: ReadarrTaskName::Backup, + interval: 120, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:00:00Z").unwrap(), + ), + last_duration: "00:00:00.5111547".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T22:30:00Z").unwrap(), + ), + }, + ReadarrTask { + name: "Rss sync task".to_owned(), + task_name: ReadarrTaskName::RssSync, + interval: 15, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:22:00Z").unwrap(), + ), + last_duration: "00:00:01.2233445".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:33:00Z").unwrap(), + ), + }, + ReadarrTask { + name: "Housekeeping task".to_owned(), + task_name: ReadarrTaskName::Housekeeping, + interval: 1440, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T17:30:00Z").unwrap(), + ), + last_duration: "00:00:09.9887766".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-21T15:30:00Z").unwrap(), + ), + }, + ] + } + + fn queued_events_vec() -> Vec { + vec![ + QueueEvent { + trigger: "manual".to_owned(), + name: "event name one".to_owned(), + command_name: "queued event one".to_owned(), + status: "completed".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T20:30:00Z").unwrap()), + started: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:00:00Z").unwrap(), + )), + ended: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:29:14Z").unwrap(), + )), + duration: Some("00:29:14.1234567".to_owned()), + }, + QueueEvent { + trigger: "scheduled".to_owned(), + name: "event name two".to_owned(), + command_name: "queued event two".to_owned(), + status: "started".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:15:00Z").unwrap()), + started: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:26:00Z").unwrap(), + )), + ended: None, + duration: Some("01:02:03.7654321".to_owned()), + }, + QueueEvent { + trigger: "external".to_owned(), + name: "event name three".to_owned(), + command_name: "queued event three".to_owned(), + status: "queued".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:00Z").unwrap()), + started: None, + ended: None, + duration: None, + }, + ] + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha".into(), + "2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } +} From a7178a481bc6ad047fc8ce62874cfb625f56bb75 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 10:10:25 -0600 Subject: [PATCH 099/170] feat(readarr): add the indexers handlers Add IndexersHandler and its three sub-handlers, EditIndexerHandler, IndexerSettingsHandler and TestAllIndexersHandler, and dispatch to them from ReadarrHandler. IndexersHandler::accepts ORs in all three children, so the parent and the children land together. Splitting would leave eighteen blocks accepted but inert, with Esc falling through to handle_clear_errors instead of popping. TestAllIndexersHandler::accepts compares the block for equality rather than consulting an array, since TestAllIndexers appears in no block array. --- .../indexers/edit_indexer_handler.rs | 532 +++++ .../indexers/edit_indexer_handler_tests.rs | 1925 +++++++++++++++++ .../indexers/edit_indexer_settings_handler.rs | 221 ++ .../edit_indexer_settings_handler_tests.rs | 610 ++++++ .../indexers/indexers_handler_tests.rs | 750 +++++++ src/handlers/readarr_handlers/indexers/mod.rs | 217 ++ .../indexers/test_all_indexers_handler.rs | 108 + .../test_all_indexers_handler_tests.rs | 133 ++ src/handlers/readarr_handlers/mod.rs | 5 + .../readarr_handlers/readarr_handler_tests.rs | 24 + 10 files changed, 4525 insertions(+) create mode 100644 src/handlers/readarr_handlers/indexers/edit_indexer_handler.rs create mode 100644 src/handlers/readarr_handlers/indexers/edit_indexer_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler.rs create mode 100644 src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/indexers/indexers_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/indexers/mod.rs create mode 100644 src/handlers/readarr_handlers/indexers/test_all_indexers_handler.rs create mode 100644 src/handlers/readarr_handlers/indexers/test_all_indexers_handler_tests.rs diff --git a/src/handlers/readarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/readarr_handlers/indexers/edit_indexer_handler.rs new file mode 100644 index 00000000..fef24458 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/edit_indexer_handler.rs @@ -0,0 +1,532 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::Route; +use crate::models::servarr_data::modals::EditIndexerModal; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, EDIT_INDEXER_BLOCKS}; +use crate::models::servarr_models::EditIndexerParams; +use crate::network::readarr_network::ReadarrEvent; +use crate::{ + handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys, matches_key, +}; + +#[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_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl EditIndexerHandler<'_, '_> { + fn build_edit_indexer_params(&mut self) -> EditIndexerParams { + let edit_indexer_modal = self + .app + .data + .readarr_data + .edit_indexer_modal + .take() + .expect("EditIndexerModal is None"); + let indexer_id = self.app.data.readarr_data.indexers.current_selection().id; + let tags = edit_indexer_modal.tags.text; + let EditIndexerModal { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + priority, + .. + } = edit_indexer_modal; + + EditIndexerParams { + indexer_id, + name: Some(name.text), + enable_rss, + enable_automatic_search, + enable_interactive_search, + url: Some(url.text), + api_key: Some(api_key.text), + seed_ratio: Some(seed_ratio.text), + tags: None, + tag_input_string: Some(tags), + priority: Some(priority), + clear_tags: false, + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for EditIndexerHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + EDIT_INDEXER_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> EditIndexerHandler<'a, 'b> { + EditIndexerHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.readarr_data.edit_indexer_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + self.app.data.readarr_data.selected_block.up(); + } + ActiveReadarrBlock::EditIndexerPriorityInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .priority += 1; + } + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + self.app.data.readarr_data.selected_block.down(); + } + ActiveReadarrBlock::EditIndexerPriorityInput => { + let edit_indexer_modal = self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + if edit_indexer_modal.priority > 1 { + edit_indexer_modal.priority -= 1; + } + } + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerNameInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + .scroll_home(); + } + ActiveReadarrBlock::EditIndexerUrlInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + .scroll_home(); + } + ActiveReadarrBlock::EditIndexerApiKeyInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + .scroll_home(); + } + ActiveReadarrBlock::EditIndexerSeedRatioInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + .scroll_home(); + } + ActiveReadarrBlock::EditIndexerTagsInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + .scroll_home(); + } + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerNameInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + .reset_offset(); + } + ActiveReadarrBlock::EditIndexerUrlInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + .reset_offset(); + } + ActiveReadarrBlock::EditIndexerApiKeyInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + .reset_offset(); + } + ActiveReadarrBlock::EditIndexerSeedRatioInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + .reset_offset(); + } + ActiveReadarrBlock::EditIndexerTagsInput => { + self + .app + .data + .readarr_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_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + handle_prompt_left_right_keys!( + self, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + readarr_data + ); + } + ActiveReadarrBlock::EditIndexerNameInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + ); + } + ActiveReadarrBlock::EditIndexerUrlInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + ); + } + ActiveReadarrBlock::EditIndexerApiKeyInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + ); + } + ActiveReadarrBlock::EditIndexerSeedRatioInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + ); + } + ActiveReadarrBlock::EditIndexerTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + ); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + let selected_block = self.app.data.readarr_data.selected_block.get_active_block(); + match selected_block { + ActiveReadarrBlock::EditIndexerConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::EditIndexer(self.build_edit_indexer_params())); + self.app.should_refresh = true; + } else { + self.app.data.readarr_data.edit_indexer_modal = None; + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::EditIndexerNameInput + | ActiveReadarrBlock::EditIndexerUrlInput + | ActiveReadarrBlock::EditIndexerApiKeyInput + | ActiveReadarrBlock::EditIndexerSeedRatioInput + | ActiveReadarrBlock::EditIndexerTagsInput => { + self.app.push_navigation_stack(selected_block.into()); + self.app.ignore_special_keys_for_textbox_input = true; + } + ActiveReadarrBlock::EditIndexerPriorityInput => self + .app + .push_navigation_stack(ActiveReadarrBlock::EditIndexerPriorityInput.into()), + ActiveReadarrBlock::EditIndexerToggleEnableRss => { + let indexer = self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + indexer.enable_rss = Some(!indexer.enable_rss.unwrap_or_default()); + } + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch => { + let indexer = self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + indexer.enable_automatic_search = + Some(!indexer.enable_automatic_search.unwrap_or_default()); + } + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch => { + let indexer = self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + indexer.enable_interactive_search = + Some(!indexer.enable_interactive_search.unwrap_or_default()); + } + _ => (), + } + } + ActiveReadarrBlock::EditIndexerNameInput + | ActiveReadarrBlock::EditIndexerUrlInput + | ActiveReadarrBlock::EditIndexerApiKeyInput + | ActiveReadarrBlock::EditIndexerSeedRatioInput + | ActiveReadarrBlock::EditIndexerTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::EditIndexerPriorityInput => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + self.app.data.readarr_data.edit_indexer_modal = None; + } + ActiveReadarrBlock::EditIndexerNameInput + | ActiveReadarrBlock::EditIndexerUrlInput + | ActiveReadarrBlock::EditIndexerApiKeyInput + | ActiveReadarrBlock::EditIndexerSeedRatioInput + | ActiveReadarrBlock::EditIndexerPriorityInput + | ActiveReadarrBlock::EditIndexerTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => self.app.pop_navigation_stack(), + } + } + + fn handle_char_key_event(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerNameInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + ); + } + ActiveReadarrBlock::EditIndexerUrlInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + ); + } + ActiveReadarrBlock::EditIndexerApiKeyInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + ); + } + ActiveReadarrBlock::EditIndexerSeedRatioInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + ); + } + ActiveReadarrBlock::EditIndexerTagsInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + ); + } + ActiveReadarrBlock::EditIndexerPrompt + if self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::EditIndexerConfirmPrompt + && matches_key!(confirm, self.key) => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::EditIndexer(self.build_edit_indexer_params())); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/readarr_handlers/indexers/edit_indexer_handler_tests.rs new file mode 100644 index 00000000..2ba69b47 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -0,0 +1,1925 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_modal_present; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_BLOCKS, + }; + use crate::models::servarr_models::{EditIndexerParams, Indexer}; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use pretty_assertions::assert_eq; + use rstest::rstest; + 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::BlockSelectionState; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_edit_indexer_priority_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + + if key == Key::Up { + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 2 + ); + } else { + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 1 + ); + + EditIndexerHandler::new( + Key::Up, + &mut app, + ActiveReadarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 2 + ); + + EditIndexerHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 1 + ); + } + } + + #[rstest] + fn test_edit_indexer_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerNameInput + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch + ); + } + } + + #[rstest] + fn test_edit_indexer_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::BlockSelectionState; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::{ + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + }; + 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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1; + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + #[case( + 0, + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput + )] + #[case( + 1, + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput + )] + #[case( + 2, + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerSeedRatioInput + )] + #[case( + 3, + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerTagsInput + )] + fn test_left_right_block_toggle_torrents( + #[values(Key::Left, Key::Right)] key: Key, + #[case] starting_y_index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = starting_y_index; + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + left_block + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + right_block + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + left_block + ); + } + + #[rstest] + #[case( + 0, + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput + )] + #[case( + 1, + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput + )] + #[case( + 2, + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerTagsInput + )] + #[case( + 3, + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerPriorityInput + )] + fn test_left_right_block_toggle_nzb( + #[values(Key::Left, Key::Right)] key: Key, + #[case] starting_y_index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = starting_y_index; + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + left_block + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + right_block + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = 4; + app.data.readarr_data.prompt_confirm = false; + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerPriorityInput + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerConfirmPrompt + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerConfirmPrompt + ); + assert!(app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_edit_indexer_name_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_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::assert_navigation_popped; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::{ + BlockSelectionState, + servarr_data::readarr::readarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + }; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_indexer_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert_none!(app.data.readarr_data.edit_indexer_modal); + } + + #[test] + fn test_edit_indexer_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + priority: 0, + }; + app.data.readarr_data.edit_indexer_modal = Some(edit_indexer_modal); + app.data.readarr_data.indexers.set_items(vec![indexer()]); + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + app.data.readarr_data.prompt_confirm = true; + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_modal_absent!(app.data.readarr_data.edit_indexer_modal); + assert!(app.should_refresh); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::EditIndexer(expected_edit_indexer_params)) + ); + } + + #[test] + fn test_edit_indexer_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.prompt_confirm = true; + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert_modal_present!(app.data.readarr_data.edit_indexer_modal); + assert!(!app.should_refresh); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[rstest] + #[case(0, 0, ActiveReadarrBlock::EditIndexerNameInput)] + #[case(0, 1, ActiveReadarrBlock::EditIndexerUrlInput)] + #[case(1, 1, ActiveReadarrBlock::EditIndexerApiKeyInput)] + #[case(2, 1, ActiveReadarrBlock::EditIndexerSeedRatioInput)] + #[case(3, 1, ActiveReadarrBlock::EditIndexerTagsInput)] + fn test_edit_indexer_prompt_submit_input_fields( + #[case] starting_y: usize, + #[case] starting_x: usize, + #[case] block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(starting_x, starting_y); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_pushed!(app, block.into()); + assert!(app.ignore_special_keys_for_textbox_input); + } + + #[test] + fn test_edit_indexer_priority_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, 4); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::EditIndexerPriorityInput.into()); + assert!(!app.ignore_special_keys_for_textbox_input); + } + + #[test] + fn test_edit_indexer_toggle_enable_rss_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, 1); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_rss + .unwrap() + ); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_rss + .unwrap() + ); + } + + #[test] + fn test_edit_indexer_toggle_enable_automatic_search_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, 2); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_automatic_search + .unwrap() + ); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + !app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, 3); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_interactive_search + .unwrap() + ); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_interactive_search + .unwrap() + ); + } + + #[test] + fn test_edit_indexer_name_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerNameInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + + #[test] + fn test_edit_indexer_url_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerUrlInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + + #[test] + fn test_edit_indexer_api_key_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerApiKeyInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerSeedRatioInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + + #[test] + fn test_edit_indexer_tags_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerTagsInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + } + + mod test_handle_esc { + use super::*; + use crate::app::App; + use crate::assert_navigation_popped; + use crate::event::Key; + use crate::models::servarr_data::modals::EditIndexerModal; + 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::test_default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.edit_indexer_modal); + } + + #[rstest] + fn test_edit_indexer_input_fields_esc( + #[values( + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + ActiveReadarrBlock::EditIndexerTagsInput, + ActiveReadarrBlock::EditIndexerPriorityInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.ignore_special_keys_for_textbox_input = true; + + EditIndexerHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert!(!app.ignore_special_keys_for_textbox_input); + assert_some_eq_x!( + &app.data.readarr_data.edit_indexer_modal, + &EditIndexerModal::default() + ); + } + } + + mod test_handle_key_char { + use super::*; + use crate::app::App; + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use pretty_assertions::{assert_eq, assert_str_eq}; + + #[test] + fn test_edit_indexer_name_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_url_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_api_key_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_tags_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_name_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_url_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_api_key_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_tags_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_prompt_prompt_confirmation_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + priority: 0, + }; + app.data.readarr_data.edit_indexer_modal = Some(edit_indexer_modal); + app.data.readarr_data.indexers.set_items(vec![indexer()]); + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_modal_absent!(app.data.readarr_data.edit_indexer_modal); + assert!(app.should_refresh); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::EditIndexer(expected_edit_indexer_params)) + ); + } + } + + #[test] + fn test_edit_indexer_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDIT_INDEXER_BLOCKS.contains(&active_readarr_block) { + assert!(EditIndexerHandler::accepts(active_readarr_block)); + } else { + assert!(!EditIndexerHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_edit_indexer_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_edit_indexer_params() { + let mut app = App::test_default(); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(true), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + priority: 0, + }; + app.data.readarr_data.edit_indexer_modal = Some(edit_indexer_modal); + let decoy_indexer = Indexer { id: 3, ..indexer() }; + app + .data + .readarr_data + .indexers + .set_items(vec![decoy_indexer, indexer()]); + app.data.readarr_data.indexers.select_index(Some(1)); + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(true), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + + let params = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .build_edit_indexer_params(); + + assert_eq!(params, expected_edit_indexer_params); + assert_modal_absent!(app.data.readarr_data.edit_indexer_modal); + } + + #[test] + fn test_edit_indexer_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_handler_is_ready_when_edit_indexer_modal_is_some() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + let handler = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler.rs b/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler.rs new file mode 100644 index 00000000..524daa11 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler.rs @@ -0,0 +1,221 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, INDEXER_SETTINGS_BLOCKS, +}; +use crate::models::servarr_models::IndexerSettings; +use crate::network::readarr_network::ReadarrEvent; +use crate::{handle_prompt_left_right_keys, matches_key}; + +#[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_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl IndexerSettingsHandler<'_, '_> { + fn build_edit_indexer_settings_params(&mut self) -> IndexerSettings { + self + .app + .data + .readarr_data + .indexer_settings + .take() + .expect("IndexerSettings is None") + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for IndexerSettingsHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + INDEXER_SETTINGS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> IndexerSettingsHandler<'a, 'b> { + IndexerSettingsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.readarr_data.indexer_settings.is_some() + } + + fn handle_scroll_up(&mut self) { + let indexer_settings = self + .app + .data + .readarr_data + .indexer_settings + .as_mut() + .unwrap(); + match self.active_readarr_block { + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + self.app.data.readarr_data.selected_block.up(); + } + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput => { + indexer_settings.minimum_age += 1; + } + ActiveReadarrBlock::IndexerSettingsRetentionInput => { + indexer_settings.retention += 1; + } + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput => { + indexer_settings.maximum_size += 1; + } + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput => { + indexer_settings.rss_sync_interval += 1; + } + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + let indexer_settings = self + .app + .data + .readarr_data + .indexer_settings + .as_mut() + .unwrap(); + match self.active_readarr_block { + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + self.app.data.readarr_data.selected_block.down() + } + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput => { + if indexer_settings.minimum_age > 0 { + indexer_settings.minimum_age -= 1; + } + } + ActiveReadarrBlock::IndexerSettingsRetentionInput => { + if indexer_settings.retention > 0 { + indexer_settings.retention -= 1; + } + } + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput => { + if indexer_settings.maximum_size > 0 { + indexer_settings.maximum_size -= 1; + } + } + ActiveReadarrBlock::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_readarr_block == ActiveReadarrBlock::AllIndexerSettingsPrompt { + handle_prompt_left_right_keys!( + self, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + readarr_data + ); + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::IndexerSettingsConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::EditAllIndexerSettings(self.build_edit_indexer_settings_params()), + ); + self.app.should_refresh = true; + } else { + self.app.data.readarr_data.indexer_settings = None; + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput + | ActiveReadarrBlock::IndexerSettingsRetentionInput + | ActiveReadarrBlock::IndexerSettingsMaximumSizeInput + | ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput => { + self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + None, + ) + .into(), + ) + } + + _ => (), + } + } + + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput + | ActiveReadarrBlock::IndexerSettingsRetentionInput + | ActiveReadarrBlock::IndexerSettingsMaximumSizeInput + | ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + self.app.data.readarr_data.indexer_settings = None; + } + _ => self.app.pop_navigation_stack(), + } + } + + fn handle_char_key_event(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::AllIndexerSettingsPrompt + && self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::IndexerSettingsConfirmPrompt + && matches_key!(confirm, self.key) + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::EditAllIndexerSettings(self.build_edit_indexer_settings_params()), + ); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler_tests.rs b/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler_tests.rs new file mode 100644 index 00000000..2e6b3fb3 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler_tests.rs @@ -0,0 +1,610 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, INDEXER_SETTINGS_BLOCKS, + }; + use crate::models::servarr_models::IndexerSettings; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer_settings; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS; + use crate::models::servarr_models::IndexerSettings; + + use super::*; + + macro_rules! test_i64_counter_scroll_value { + ($block:expr, $key:expr, $data_ref:ident, $negatives:literal) => { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::new($key, &mut app, $block, None).handle(); + + if $key == Key::Up { + assert_eq!( + app + .data + .readarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 1 + ); + } else { + if $negatives { + assert_eq!( + app + .data + .readarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + -1 + ); + } else { + assert_eq!( + app + .data + .readarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 0 + ); + + IndexerSettingsHandler::new(Key::Up, &mut app, $block, None).handle(); + + assert_eq!( + app + .data + .readarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 1 + ); + + IndexerSettingsHandler::new($key, &mut app, $block, None).handle(); + assert_eq!( + app + .data + .readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + IndexerSettingsHandler::new( + key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + IndexerSettingsHandler::new( + key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::IndexerSettingsRetentionInput + ); + } + + #[rstest] + fn test_edit_indexer_settings_minimum_age_scroll(#[values(Key::Up, Key::Down)] key: Key) { + test_i64_counter_scroll_value!( + ActiveReadarrBlock::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!( + ActiveReadarrBlock::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!( + ActiveReadarrBlock::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!( + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, + key, + rss_sync_interval, + false + ); + } + } + + mod test_handle_left_right_action { + use crate::models::servarr_data::readarr::readarr_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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1; + + IndexerSettingsHandler::new( + key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + IndexerSettingsHandler::new( + key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::{ + assert_navigation_popped, + models::{ + BlockSelectionState, + servarr_data::readarr::readarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, + servarr_models::IndexerSettings, + }, + network::readarr_network::ReadarrEvent, + }; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_indexer_settings_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert_none!(app.data.readarr_data.indexer_settings); + } + + #[test] + fn test_edit_indexer_settings_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + app.data.readarr_data.prompt_confirm = true; + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::EditAllIndexerSettings(indexer_settings()) + ); + assert_modal_absent!(app.data.readarr_data.indexer_settings); + assert!(app.should_refresh); + } + + #[test] + fn test_edit_indexer_settings_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.readarr_data.prompt_confirm = true; + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AllIndexerSettingsPrompt.into() + ); + assert!(!app.should_refresh); + } + + #[rstest] + #[case(ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, 0)] + #[case(ActiveReadarrBlock::IndexerSettingsRetentionInput, 1)] + #[case(ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, 2)] + #[case(ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, 3)] + fn test_edit_indexer_settings_prompt_submit_selected_block( + #[case] selected_block: ActiveReadarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_pushed!(app, 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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AllIndexerSettingsPrompt.into() + ); + } + + #[rstest] + fn test_edit_indexer_settings_selected_block_submit( + #[values( + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + IndexerSettingsHandler::new(SUBMIT_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use crate::models::servarr_models::IndexerSettings; + + use super::*; + use crate::assert_navigation_popped; + + 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::test_default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.indexer_settings); + } + + #[rstest] + fn test_edit_indexer_settings_selected_blocks_esc( + #[values( + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_some_eq_x!( + &app.data.readarr_data.indexer_settings, + &IndexerSettings::default() + ); + } + } + + mod test_handle_key_char { + use crate::{ + assert_navigation_popped, + models::{ + BlockSelectionState, servarr_data::readarr::readarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, + }, + network::readarr_network::ReadarrEvent, + }; + + use super::*; + + #[test] + fn test_edit_indexer_settings_prompt_prompt_confirmation_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + + IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::EditAllIndexerSettings(indexer_settings()) + ); + assert_modal_absent!(app.data.readarr_data.indexer_settings); + assert!(app.should_refresh); + } + } + + #[test] + fn test_indexer_settings_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if INDEXER_SETTINGS_BLOCKS.contains(&active_readarr_block) { + assert!(IndexerSettingsHandler::accepts(active_readarr_block)); + } else { + assert!(!IndexerSettingsHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_indexer_settings_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_edit_indexer_settings_params() { + let mut app = App::test_default(); + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + + let actual_indexer_settings = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .build_edit_indexer_settings_params(); + + assert_eq!(actual_indexer_settings, indexer_settings()); + assert_modal_absent!(app.data.readarr_data.indexer_settings); + } + + #[test] + fn test_edit_indexer_settings_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_settings_handler_not_ready_when_indexer_settings_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + let handler = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/readarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/readarr_handlers/indexers/indexers_handler_tests.rs new file mode 100644 index 00000000..9ad8265b --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/indexers_handler_tests.rs @@ -0,0 +1,750 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::indexers::IndexersHandler; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXERS_BLOCKS, + }; + use crate::models::servarr_models::Indexer; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use crate::test_handler_delegation; + + 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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteIndexerPrompt.into()); + } + + #[test] + fn test_delete_indexer_prompt_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(5); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::RootFolders.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::RootFolders.into()); + } + + #[rstest] + fn test_indexers_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(5); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::System.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::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::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + IndexersHandler::new(key, &mut app, ActiveReadarrBlock::DeleteIndexerPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + IndexersHandler::new(key, &mut app, ActiveReadarrBlock::DeleteIndexerPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use super::*; + use crate::assert_navigation_popped; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::{ + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, ReadarrData, + }; + use crate::models::servarr_models::{Indexer, IndexerField}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use bimap::BiMap; + use pretty_assertions::assert_eq; + use serde_json::{Number, Value}; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[rstest] + fn test_edit_indexer_submit(#[values(true, false)] torrent_protocol: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + let protocol = if torrent_protocol { + "torrent".to_owned() + } else { + "usenet".to_owned() + }; + let decoy_protocol = if torrent_protocol { + "usenet".to_owned() + } else { + "torrent".to_owned() + }; + let mut expected_edit_indexer_modal = EditIndexerModal { + name: "Test".into(), + enable_rss: Some(true), + enable_automatic_search: Some(false), + enable_interactive_search: Some(true), + url: "https://test.com".into(), + api_key: "1234".into(), + tags: "usenet, test".into(), + ..EditIndexerModal::default() + }; + let mut readarr_data = ReadarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..ReadarrData::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: false, + enable_interactive_search: true, + protocol, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + ..Indexer::default() + }; + let decoy_indexer = Indexer { + name: Some("Decoy".to_owned()), + enable_rss: false, + enable_automatic_search: true, + enable_interactive_search: false, + protocol: decoy_protocol, + tags: vec![Number::from(2)], + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://decoy.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("9999".to_owned())), + }, + ]), + ..Indexer::default() + }; + readarr_data + .indexers + .set_items(vec![decoy_indexer, indexer]); + readarr_data.indexers.select_index(Some(1)); + app.data.readarr_data = readarr_data; + + IndexersHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + assert_some_eq_x!( + &app.data.readarr_data.edit_indexer_modal, + &EditIndexerModal::from(&app.data.readarr_data) + ); + assert_some_eq_x!( + &app.data.readarr_data.edit_indexer_modal, + &expected_edit_indexer_modal + ); + if torrent_protocol { + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_INDEXER_TORRENT_SELECTION_BLOCKS + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_INDEXER_NZB_SELECTION_BLOCKS + ); + } + } + + #[test] + fn test_edit_indexer_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.edit_indexer_modal); + } + + #[test] + fn test_delete_indexer_prompt_confirm_submit() { + let mut app = App::test_default(); + app.data.readarr_data.indexers.set_items(vec![indexer()]); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + + IndexersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::DeleteIndexer(8) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + } + + #[test] + fn test_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + + IndexersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + } + } + + mod test_handle_esc { + + use super::*; + use crate::assert_navigation_popped; + + 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::test_default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + IndexersHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_test_indexer_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.indexer_test_errors = Some("test result".to_owned()); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + + IndexersHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::TestIndexer, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.indexer_test_errors); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + IndexersHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use crate::{ + assert_navigation_popped, + models::servarr_data::readarr::readarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, + network::readarr_network::ReadarrEvent, + }; + + #[test] + fn test_refresh_indexers_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::Indexers.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_indexers_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_indexer_settings_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.settings.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + INDEXER_SETTINGS_SELECTION_BLOCKS + ); + } + + #[test] + fn test_indexer_settings_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.settings.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + } + + #[test] + fn test_test_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.test.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::TestIndexer.into()); + } + + #[test] + fn test_test_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.test.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + } + + #[test] + fn test_test_all_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.test_all.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::TestAllIndexers.into()); + } + + #[test] + fn test_test_all_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.test_all.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + } + + #[test] + fn test_delete_indexer_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.indexers.set_items(vec![indexer()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::DeleteIndexer(8) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + } + } + + #[rstest] + fn test_delegates_edit_indexer_blocks_to_edit_indexer_handler( + #[values( + ActiveReadarrBlock::EditIndexerPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerUrlInput, + ActiveReadarrBlock::EditIndexerTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + IndexersHandler, + ActiveReadarrBlock::Indexers, + active_readarr_block + ); + } + + #[rstest] + fn test_delegates_indexer_settings_blocks_to_indexer_settings_handler( + #[values( + ActiveReadarrBlock::AllIndexerSettingsPrompt, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + IndexersHandler, + ActiveReadarrBlock::Indexers, + active_readarr_block + ); + } + + #[test] + fn test_delegates_test_all_indexers_block_to_test_all_indexers_handler() { + test_handler_delegation!( + IndexersHandler, + ActiveReadarrBlock::Indexers, + ActiveReadarrBlock::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(ActiveReadarrBlock::TestAllIndexers); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if indexers_blocks.contains(&active_readarr_block) { + assert!(IndexersHandler::accepts(active_readarr_block)); + } else { + assert!(!IndexersHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_indexers_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_indexer_id() { + let mut app = App::test_default(); + let decoy_indexer = Indexer { id: 3, ..indexer() }; + app + .data + .readarr_data + .indexers + .set_items(vec![decoy_indexer, indexer()]); + app.data.readarr_data.indexers.select_index(Some(1)); + + let indexer_id = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .extract_indexer_id(); + + assert_eq!(indexer_id, 8); + } + + #[test] + fn test_indexers_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_indexers_handler_not_ready_when_indexers_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_indexers_handler_ready_when_not_loading_and_indexers_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + let handler = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/readarr_handlers/indexers/mod.rs b/src/handlers/readarr_handlers/indexers/mod.rs new file mode 100644 index 00000000..52b8c431 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/mod.rs @@ -0,0 +1,217 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::readarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; +use crate::handlers::readarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; +use crate::handlers::readarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, +}; +use crate::models::{BlockSelectionState, Route}; +use crate::network::readarr_network::ReadarrEvent; + +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_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl IndexersHandler<'_, '_> { + fn extract_indexer_id(&self) -> i64 { + self.app.data.readarr_data.indexers.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for IndexersHandler<'a, 'b> { + fn handle(&mut self) { + let indexers_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::Indexers.into()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.indexers, + indexers_table_handling_config, + ) { + match self.active_readarr_block { + _ if EditIndexerHandler::accepts(self.active_readarr_block) => { + EditIndexerHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle() + } + _ if IndexerSettingsHandler::accepts(self.active_readarr_block) => { + IndexerSettingsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle() + } + _ if TestAllIndexersHandler::accepts(self.active_readarr_block) => { + TestAllIndexersHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle() + } + _ => self.handle_key_event(), + } + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + EditIndexerHandler::accepts(active_block) + || IndexerSettingsHandler::accepts(active_block) + || TestAllIndexersHandler::accepts(active_block) + || INDEXERS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> IndexersHandler<'a, 'b> { + IndexersHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.indexers.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) { + if self.active_readarr_block == ActiveReadarrBlock::Indexers { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Indexers => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::DeleteIndexerPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteIndexerPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteIndexer(self.extract_indexer_id())); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::Indexers => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + self.app.data.readarr_data.edit_indexer_modal = Some((&self.app.data.readarr_data).into()); + let protocol = &self + .app + .data + .readarr_data + .indexers + .current_selection() + .protocol; + if protocol == "torrent" { + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + } else { + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + } + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteIndexerPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::TestIndexer => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.indexer_test_errors = None; + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::Indexers => match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ if matches_key!(test, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + } + _ if matches_key!(test_all, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + } + _ if matches_key!(settings, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + } + _ => (), + }, + ActiveReadarrBlock::DeleteIndexerPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteIndexer(self.extract_indexer_id())); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/indexers/test_all_indexers_handler.rs b/src/handlers/readarr_handlers/indexers/test_all_indexers_handler.rs new file mode 100644 index 00000000..178620c4 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/test_all_indexers_handler.rs @@ -0,0 +1,108 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + +#[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_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl TestAllIndexersHandler<'_, '_> {} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for TestAllIndexersHandler<'a, 'b> { + fn handle(&mut self) { + let indexer_test_all_results_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::TestAllIndexers.into()); + + if !handle_table( + self, + |app| { + app + .data + .readarr_data + .indexer_test_all_results + .as_mut() + .unwrap() + }, + indexer_test_all_results_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + active_block == ActiveReadarrBlock::TestAllIndexers + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> TestAllIndexersHandler<'a, 'b> { + TestAllIndexersHandler { + key, + app, + active_readarr_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.readarr_data.indexer_test_all_results { + !table.is_empty() + } else { + false + }; + + !self.app.is_loading && table_is_ready + } + + 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) { + if self.active_readarr_block == ActiveReadarrBlock::TestAllIndexers { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.indexer_test_all_results = None; + } + } + + fn handle_char_key_event(&mut self) {} + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/indexers/test_all_indexers_handler_tests.rs b/src/handlers/readarr_handlers/indexers/test_all_indexers_handler_tests.rs new file mode 100644 index 00000000..ba912014 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/test_all_indexers_handler_tests.rs @@ -0,0 +1,133 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_popped; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + mod test_handle_esc { + use super::*; + + const ESC_KEY: crate::event::Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_test_all_indexers_prompt_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.data.readarr_data.indexer_test_all_results = Some(StatefulTable::default()); + + TestAllIndexersHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::TestAllIndexers, None) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.indexer_test_all_results); + } + } + + #[test] + fn test_test_all_indexers_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if active_readarr_block == ActiveReadarrBlock::TestAllIndexers { + assert!(TestAllIndexersHandler::accepts(active_readarr_block)); + } else { + assert!(!TestAllIndexersHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_test_all_indexers_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_test_all_indexers_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.is_loading = true; + + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::TestAllIndexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_test_all_indexers_handler_not_ready_when_results_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.is_loading = false; + + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::TestAllIndexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_test_all_indexers_handler_not_ready_when_results_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.is_loading = false; + app.data.readarr_data.indexer_test_all_results = Some(StatefulTable::default()); + + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::TestAllIndexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_test_all_indexers_handler_ready_when_not_loading_and_results_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.is_loading = false; + let mut results = StatefulTable::default(); + results.set_items(vec![IndexerTestResultModalItem::default()]); + app.data.readarr_data.indexer_test_all_results = Some(results); + + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::TestAllIndexers, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 759f172d..c2961253 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -2,6 +2,7 @@ use super::KeyEventHandler; use crate::handlers::readarr_handlers::blocklist::BlocklistHandler; use crate::handlers::readarr_handlers::downloads::DownloadsHandler; use crate::handlers::readarr_handlers::history::HistoryHandler; +use crate::handlers::readarr_handlers::indexers::IndexersHandler; use crate::handlers::readarr_handlers::library::LibraryHandler; use crate::handlers::readarr_handlers::root_folders::RootFoldersHandler; use crate::handlers::readarr_handlers::system::SystemHandler; @@ -14,6 +15,7 @@ use crate::{ mod blocklist; mod downloads; mod history; +mod indexers; mod library; mod root_folders; mod system; @@ -48,6 +50,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, RootFoldersHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); } + _ if IndexersHandler::accepts(self.active_readarr_block) => { + IndexersHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } _ if SystemHandler::accepts(self.active_readarr_block) => { SystemHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); } diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index 00504374..445aab40 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -247,6 +247,30 @@ mod tests { ); } + #[rstest] + fn test_delegates_indexers_blocks_to_indexers_handler( + #[values( + ActiveReadarrBlock::Indexers, + ActiveReadarrBlock::DeleteIndexerPrompt, + ActiveReadarrBlock::TestIndexer, + ActiveReadarrBlock::TestAllIndexers, + ActiveReadarrBlock::EditIndexerPrompt, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::Indexers, + active_readarr_block + ); + } + #[rstest] fn test_delegates_system_blocks_to_system_handler( #[values( From 59346973fcbf90c8d15209437514404a4f347e43 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 10:38:52 -0600 Subject: [PATCH 100/170] feat(readarr): add the indexers UI Render the indexers table with its delete and test popups, the edit indexer prompt with separate torrent and usenet field grids, the prompt for configuring all indexer settings, and the test all indexers results table. All four are dispatched from the Readarr UI. Extract indexer_flag_text, decorate_test_result_row_with_style and the two prompt highlight builders as named functions so their branches can be unit tested. Rendering to a string discards colour, so a snapshot cannot tell a success style from a failure style. --- src/ui/readarr_ui/indexers/edit_indexer_ui.rs | 202 ++++++++++++ .../indexers/edit_indexer_ui_tests.rs | 305 ++++++++++++++++++ .../indexers/indexer_settings_ui.rs | 139 ++++++++ .../indexers/indexer_settings_ui_tests.rs | 162 ++++++++++ .../readarr_ui/indexers/indexers_ui_tests.rs | 266 +++++++++++++++ src/ui/readarr_ui/indexers/mod.rs | 182 +++++++++++ ...ndexer_torrent_EditIndexerApiKeyInput.snap | 42 +++ ...exer_torrent_EditIndexerConfirmPrompt.snap | 42 +++ ..._indexer_torrent_EditIndexerNameInput.snap | 42 +++ ...exer_torrent_EditIndexerPriorityInput.snap | 42 +++ ...dit_indexer_torrent_EditIndexerPrompt.snap | 42 +++ ...xer_torrent_EditIndexerSeedRatioInput.snap | 42 +++ ..._indexer_torrent_EditIndexerTagsInput.snap | 42 +++ ...ditIndexerToggleEnableAutomaticSearch.snap | 42 +++ ...tIndexerToggleEnableInteractiveSearch.snap | 42 +++ ...er_torrent_EditIndexerToggleEnableRss.snap | 42 +++ ...t_indexer_torrent_EditIndexerUrlInput.snap | 42 +++ ...ests__edit_indexer_ui_renders_loading.snap | 42 +++ ...__edit_indexer_ui_renders_usenet_grid.snap | 42 +++ ...xer_settings_AllIndexerSettingsPrompt.snap | 40 +++ ...settings_IndexerSettingsConfirmPrompt.snap | 40 +++ ...tings_IndexerSettingsMaximumSizeInput.snap | 40 +++ ...ttings_IndexerSettingsMinimumAgeInput.snap | 40 +++ ...ettings_IndexerSettingsRetentionInput.snap | 40 +++ ...s_IndexerSettingsRssSyncIntervalInput.snap | 40 +++ ...__indexer_settings_ui_renders_loading.snap | 40 +++ ...ests__indexers_ui_DeleteIndexerPrompt.snap | 38 +++ ..._snapshot_tests__indexers_ui_Indexers.snap | 8 + ...apshot_tests__indexers_ui_TestIndexer.snap | 35 ++ ...lete_prompt_for_non_default_selection.snap | 38 +++ ...rs_edit_torrent_indexer_over_indexers.snap | 42 +++ ...ers_edit_usenet_indexer_over_indexers.snap | 42 +++ ...s__indexers_ui_renders_empty_indexers.snap | 5 + ...enders_indexer_settings_over_indexers.snap | 40 +++ ...ot_tests__indexers_ui_renders_loading.snap | 8 + ...exers_ui_renders_loading_test_results.snap | 35 ++ ...ults_when_indexer_test_errors_is_none.snap | 35 ++ ...nders_test_all_indexers_over_indexers.snap | 48 +++ ...exer_success_when_there_are_no_errors.snap | 31 ++ ...t_tests__test_all_indexers_ui_renders.snap | 48 +++ ...all_indexers_ui_renders_empty_results.snap | 48 +++ ..._ui_renders_for_non_default_selection.snap | 48 +++ ..._test_all_indexers_ui_renders_loading.snap | 48 +++ ...renders_loading_when_results_are_none.snap | 48 +++ .../indexers/test_all_indexers_ui.rs | 86 +++++ .../indexers/test_all_indexers_ui_tests.rs | 156 +++++++++ src/ui/readarr_ui/mod.rs | 3 + ...snapshot_tests__readarr_tabs_Indexers.snap | 6 +- 48 files changed, 2975 insertions(+), 3 deletions(-) create mode 100644 src/ui/readarr_ui/indexers/edit_indexer_ui.rs create mode 100644 src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs create mode 100644 src/ui/readarr_ui/indexers/indexer_settings_ui.rs create mode 100644 src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs create mode 100644 src/ui/readarr_ui/indexers/indexers_ui_tests.rs create mode 100644 src/ui/readarr_ui/indexers/mod.rs create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerApiKeyInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerNameInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPriorityInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPrompt.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerSeedRatioInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerTagsInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableAutomaticSearch.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableInteractiveSearch.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableRss.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerUrlInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_loading.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_usenet_grid.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_AllIndexerSettingsPrompt.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsConfirmPrompt.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMaximumSizeInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMinimumAgeInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRetentionInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRssSyncIntervalInput.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_loading.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_DeleteIndexerPrompt.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_Indexers.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_TestIndexer.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_delete_prompt_for_non_default_selection.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_torrent_indexer_over_indexers.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_usenet_indexer_over_indexers.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_empty_indexers.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results_when_indexer_test_errors_is_none.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_all_indexers_over_indexers.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_indexer_success_when_there_are_no_errors.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_empty_results.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_for_non_default_selection.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading.snap create mode 100644 src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading_when_results_are_none.snap create mode 100644 src/ui/readarr_ui/indexers/test_all_indexers_ui.rs create mode 100644 src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs diff --git a/src/ui/readarr_ui/indexers/edit_indexer_ui.rs b/src/ui/readarr_ui/indexers/edit_indexer_ui.rs new file mode 100644 index 00000000..353adf31 --- /dev/null +++ b/src/ui/readarr_ui/indexers/edit_indexer_ui.rs @@ -0,0 +1,202 @@ +use std::sync::atomic::Ordering; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, EDIT_INDEXER_BLOCKS}; +use crate::render_selectable_input_box; +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::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; + +#[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 { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + EDIT_INDEXER_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup(f, app, draw_edit_indexer_prompt, Size::WideLargePrompt); + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct EditIndexerPromptHighlights { + name: bool, + url: bool, + api_key: bool, + seed_ratio: bool, + tags: bool, + priority: bool, + rss: bool, + automatic_search: bool, + interactive_search: bool, + confirm: bool, +} + +fn edit_indexer_prompt_highlights( + selected_block: ActiveReadarrBlock, +) -> EditIndexerPromptHighlights { + EditIndexerPromptHighlights { + name: selected_block == ActiveReadarrBlock::EditIndexerNameInput, + url: selected_block == ActiveReadarrBlock::EditIndexerUrlInput, + api_key: selected_block == ActiveReadarrBlock::EditIndexerApiKeyInput, + seed_ratio: selected_block == ActiveReadarrBlock::EditIndexerSeedRatioInput, + tags: selected_block == ActiveReadarrBlock::EditIndexerTagsInput, + priority: selected_block == ActiveReadarrBlock::EditIndexerPriorityInput, + rss: selected_block == ActiveReadarrBlock::EditIndexerToggleEnableRss, + automatic_search: selected_block == ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + interactive_search: selected_block + == ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + confirm: selected_block == ActiveReadarrBlock::EditIndexerConfirmPrompt, + } +} + +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.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = edit_indexer_prompt_highlights(selected_block); + let edit_indexer_modal_option = &app.data.readarr_data.edit_indexer_modal; + let protocol = &app.data.readarr_data.indexers.current_selection().protocol; + + if edit_indexer_modal_option.is_some() { + f.render_widget(block, area); + let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap(); + + let [settings_area, buttons_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]) + .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::Readarr(active_readarr_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(highlights.name) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerNameInput); + let url_input_box = InputBox::new(&edit_indexer_modal.url.text) + .offset(edit_indexer_modal.url.offset.load(Ordering::SeqCst)) + .label("URL") + .highlighted(highlights.url) + .selected(active_readarr_block == ActiveReadarrBlock::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(highlights.api_key) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerApiKeyInput); + let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) + .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerTagsInput); + let priority_input_box = InputBox::new(&priority) + .cursor_after_string(false) + .label("Indexer Priority ▴▾") + .highlighted(highlights.priority) + .selected(active_readarr_block == ActiveReadarrBlock::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(highlights.seed_ratio) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerSeedRatioInput); + let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) + .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::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(highlights.rss); + let auto_search_checkbox = Checkbox::new("Enable Automatic Search") + .checked( + edit_indexer_modal + .enable_automatic_search + .unwrap_or_default(), + ) + .highlighted(highlights.automatic_search); + let interactive_search_checkbox = Checkbox::new("Enable Interactive Search") + .checked( + edit_indexer_modal + .enable_interactive_search + .unwrap_or_default(), + ) + .highlighted(highlights.interactive_search); + + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(25)]) + .flex(Flex::Center) + .areas(buttons_area); + + let save_button = Button::default() + .title("Save") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + 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); + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} diff --git a/src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs b/src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs new file mode 100644 index 00000000..c2b300ef --- /dev/null +++ b/src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs @@ -0,0 +1,305 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::indexers::edit_indexer_ui::{ + EditIndexerPromptHighlights, EditIndexerUi, edit_indexer_prompt_highlights, + }; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_edit_indexer_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDIT_INDEXER_BLOCKS.contains(&active_readarr_block) { + assert!(EditIndexerUi::accepts(active_readarr_block.into())); + } else { + assert!(!EditIndexerUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::EditIndexerNameInput, + EditIndexerPromptHighlights { + name: true, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerUrlInput, + EditIndexerPromptHighlights { + name: false, + url: true, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerApiKeyInput, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: true, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerSeedRatioInput, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: true, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerTagsInput, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: true, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerPriorityInput, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: true, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerToggleEnableRss, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: true, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: true, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerConfirmPrompt, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: true, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerPrompt, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + fn test_edit_indexer_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: EditIndexerPromptHighlights, + ) { + let highlights = edit_indexer_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + mod snapshot_tests { + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::{ + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + }; + use crate::models::servarr_models::Indexer; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + fn seed_asymmetric_enable_flags( + app: &mut App<'_>, + enable_rss: bool, + enable_automatic_search: bool, + enable_interactive_search: bool, + ) { + let edit_indexer_modal = app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .expect("edit_indexer_modal must exist in this context"); + 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); + } + + fn seed_indexer_protocol(app: &mut App<'_>, protocol: &str) { + app.data.readarr_data.indexers.set_items(vec![Indexer { + protocol: protocol.to_owned(), + ..indexer() + }]); + } + + #[test] + fn test_edit_indexer_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.data.readarr_data.edit_indexer_modal = None; + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditIndexerUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + #[case(ActiveReadarrBlock::EditIndexerPrompt, 0, 0)] + #[case(ActiveReadarrBlock::EditIndexerNameInput, 0, 0)] + #[case(ActiveReadarrBlock::EditIndexerUrlInput, 1, 0)] + #[case(ActiveReadarrBlock::EditIndexerToggleEnableRss, 0, 1)] + #[case(ActiveReadarrBlock::EditIndexerApiKeyInput, 1, 1)] + #[case(ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, 0, 2)] + #[case(ActiveReadarrBlock::EditIndexerSeedRatioInput, 1, 2)] + #[case(ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, 0, 3)] + #[case(ActiveReadarrBlock::EditIndexerTagsInput, 1, 3)] + #[case(ActiveReadarrBlock::EditIndexerPriorityInput, 0, 4)] + #[case(ActiveReadarrBlock::EditIndexerConfirmPrompt, 1, 4)] + fn test_edit_indexer_ui_renders_torrent_grid( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] x: usize, + #[case] y: usize, + ) { + let mut app = App::test_default_fully_populated(); + seed_asymmetric_enable_flags(&mut app, true, false, true); + seed_indexer_protocol(&mut app, "torrent"); + let mut selected_block = BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + selected_block.set_index(x, y); + app.data.readarr_data.selected_block = selected_block; + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditIndexerUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("edit_indexer_torrent_{active_readarr_block}"), + output + ); + } + + #[test] + fn test_edit_indexer_ui_renders_usenet_grid() { + let mut app = App::test_default_fully_populated(); + seed_asymmetric_enable_flags(&mut app, false, true, true); + seed_indexer_protocol(&mut app, "usenet"); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditIndexerUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/indexers/indexer_settings_ui.rs b/src/ui/readarr_ui/indexers/indexer_settings_ui.rs new file mode 100644 index 00000000..3eca2f83 --- /dev/null +++ b/src/ui/readarr_ui/indexers/indexer_settings_ui.rs @@ -0,0 +1,139 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, INDEXER_SETTINGS_BLOCKS, +}; +use crate::render_selectable_input_box; +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::{DrawUi, draw_popup}; + +#[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 { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + INDEXER_SETTINGS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup(f, app, draw_edit_indexer_settings_prompt, Size::LargePrompt); + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct IndexerSettingsPromptHighlights { + minimum_age: bool, + retention: bool, + maximum_size: bool, + rss_sync_interval: bool, + confirm: bool, +} + +fn indexer_settings_prompt_highlights( + selected_block: ActiveReadarrBlock, +) -> IndexerSettingsPromptHighlights { + IndexerSettingsPromptHighlights { + minimum_age: selected_block == ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + retention: selected_block == ActiveReadarrBlock::IndexerSettingsRetentionInput, + maximum_size: selected_block == ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + rss_sync_interval: selected_block == ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, + confirm: selected_block == ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + } +} + +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.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = indexer_settings_prompt_highlights(selected_block); + let indexer_settings_option = &app.data.readarr_data.indexer_settings; + + if indexer_settings_option.is_some() { + f.render_widget(block, area); + let indexer_settings = indexer_settings_option.as_ref().unwrap(); + + let [ + _, + min_age_area, + retention_area, + max_size_area, + rss_sync_area, + _, + buttons_area, + ] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + + if let Route::Readarr(active_readarr_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(highlights.minimum_age) + .selected(active_readarr_block == ActiveReadarrBlock::IndexerSettingsMinimumAgeInput); + let retention_input_box = InputBox::new(&retention) + .cursor_after_string(false) + .label("Retention (days) ▴▾") + .highlighted(highlights.retention) + .selected(active_readarr_block == ActiveReadarrBlock::IndexerSettingsRetentionInput); + let max_size_input_box = InputBox::new(&max_size) + .cursor_after_string(false) + .label("Maximum Size (MB) ▴▾") + .highlighted(highlights.maximum_size) + .selected(active_readarr_block == ActiveReadarrBlock::IndexerSettingsMaximumSizeInput); + let rss_sync_interval_input_box = InputBox::new(&rss_sync_interval) + .cursor_after_string(false) + .label("RSS Sync Interval (minutes) ▴▾") + .highlighted(highlights.rss_sync_interval) + .selected(active_readarr_block == ActiveReadarrBlock::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::default() + .title("Save") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} diff --git a/src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs b/src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs new file mode 100644 index 00000000..992d26f1 --- /dev/null +++ b/src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs @@ -0,0 +1,162 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::indexers::indexer_settings_ui::{ + IndexerSettingsPromptHighlights, IndexerSettingsUi, indexer_settings_prompt_highlights, + }; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_indexer_settings_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if INDEXER_SETTINGS_BLOCKS.contains(&active_readarr_block) { + assert!(IndexerSettingsUi::accepts(active_readarr_block.into())); + } else { + assert!(!IndexerSettingsUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + IndexerSettingsPromptHighlights { + minimum_age: true, + retention: false, + maximum_size: false, + rss_sync_interval: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::IndexerSettingsRetentionInput, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: true, + maximum_size: false, + rss_sync_interval: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: false, + maximum_size: true, + rss_sync_interval: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: false, + maximum_size: false, + rss_sync_interval: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: false, + maximum_size: false, + rss_sync_interval: false, + confirm: true, + } + )] + #[case( + ActiveReadarrBlock::AllIndexerSettingsPrompt, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: false, + maximum_size: false, + rss_sync_interval: false, + confirm: false, + } + )] + fn test_indexer_settings_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: IndexerSettingsPromptHighlights, + ) { + let highlights = indexer_settings_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_indexer_settings_prompt_highlights_covers_every_selection_step() { + for step in INDEXER_SETTINGS_SELECTION_BLOCKS { + let highlights = indexer_settings_prompt_highlights(step[0]); + + assert_ne!( + highlights, + indexer_settings_prompt_highlights(ActiveReadarrBlock::AllIndexerSettingsPrompt) + ); + } + } + + mod snapshot_tests { + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer_settings; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + fn seed_distinct_indexer_settings(app: &mut App<'_>) { + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + } + + #[test] + fn test_indexer_settings_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.data.readarr_data.indexer_settings = None; + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexerSettingsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_indexer_settings_ui_renders( + #[values( + ActiveReadarrBlock::AllIndexerSettingsPrompt, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexer_settings(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexerSettingsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("indexer_settings_{active_readarr_block}"), output); + } + } +} diff --git a/src/ui/readarr_ui/indexers/indexers_ui_tests.rs b/src/ui/readarr_ui/indexers/indexers_ui_tests.rs new file mode 100644 index 00000000..802337a0 --- /dev/null +++ b/src/ui/readarr_ui/indexers/indexers_ui_tests.rs @@ -0,0 +1,266 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXERS_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::indexers::IndexersUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[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(ActiveReadarrBlock::TestAllIndexers); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if indexers_blocks.contains(&active_readarr_block) { + assert!(IndexersUi::accepts(active_readarr_block.into())); + } else { + assert!(!IndexersUi::accepts(active_readarr_block.into())); + } + }); + } + + mod test_indexer_flag_text { + use pretty_assertions::assert_eq; + use ratatui::text::Text; + + use crate::ui::readarr_ui::indexers::indexer_flag_text; + use crate::ui::styles::ManagarrStyle; + + #[test] + fn test_indexer_flag_text_when_the_flag_is_enabled() { + let flag_text = indexer_flag_text(true); + + assert_eq!(flag_text, Text::from("Enabled").success()); + } + + #[test] + fn test_indexer_flag_text_when_the_flag_is_disabled() { + let flag_text = indexer_flag_text(false); + + assert_eq!(flag_text, Text::from("Disabled").failure()); + } + + #[test] + fn test_indexer_flag_text_styles_the_two_branches_differently() { + let enabled_flag_text = indexer_flag_text(true); + + assert_ne!(enabled_flag_text, Text::from("Enabled").failure()); + } + } + + mod snapshot_tests { + use crate::models::BlockSelectionState; + use crate::models::Scrollable; + use crate::models::servarr_data::readarr::readarr_data::{ + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, + }; + use crate::models::servarr_models::Indexer; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + indexer, indexer_settings, + }; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + use serde_json::Number; + + use super::*; + + fn seed_distinct_indexers(app: &mut App<'_>) { + app + .data + .readarr_data + .tags_map + .insert(2, "usenet-only".to_owned()); + app.data.readarr_data.indexers.set_items(vec![ + Indexer { + id: 3, + name: Some("Decoy Usenet Indexer".to_owned()), + protocol: "usenet".to_owned(), + enable_rss: false, + enable_automatic_search: true, + enable_interactive_search: true, + priority: 7, + tags: vec![Number::from(2)], + ..indexer() + }, + Indexer { + id: 8, + name: Some("Test Torrent Indexer".to_owned()), + protocol: "torrent".to_owned(), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: false, + priority: 42, + tags: vec![Number::from(1)], + ..indexer() + }, + ]); + } + + #[test] + fn test_indexers_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_empty_indexers() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_loading_test_results() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_loading_test_results_when_indexer_test_errors_is_none() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + app.data.readarr_data.indexer_test_errors = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_test_indexer_success_when_there_are_no_errors() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + app.data.readarr_data.indexer_test_errors = Some(String::new()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_indexers_ui_renders( + #[values( + ActiveReadarrBlock::Indexers, + ActiveReadarrBlock::DeleteIndexerPrompt, + ActiveReadarrBlock::TestIndexer + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("indexers_ui_{active_readarr_block}"), output); + } + + #[test] + fn test_indexers_ui_renders_delete_prompt_for_non_default_selection() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.data.readarr_data.indexers.scroll_down(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_edit_usenet_indexer_over_indexers() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_edit_torrent_indexer_over_indexers() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.data.readarr_data.indexers.scroll_down(); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_indexer_settings_over_indexers() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_test_all_indexers_over_indexers() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/indexers/mod.rs b/src/ui/readarr_ui/indexers/mod.rs new file mode 100644 index 00000000..e358e943 --- /dev/null +++ b/src/ui/readarr_ui/indexers/mod.rs @@ -0,0 +1,182 @@ +use crate::ui::styles::success_style; +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::text::Text; +use ratatui::widgets::{Cell, Row}; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, INDEXERS_BLOCKS}; +use crate::models::servarr_models::Indexer; +use crate::ui::DrawUi; +use crate::ui::readarr_ui::indexers::edit_indexer_ui::EditIndexerUi; +use crate::ui::readarr_ui::indexers::indexer_settings_ui::IndexerSettingsUi; +use crate::ui::readarr_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}; + +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::Readarr(active_readarr_block, _) = route { + return EditIndexerUi::accepts(route) + || IndexerSettingsUi::accepts(route) + || TestAllIndexersUi::accepts(route) + || INDEXERS_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + 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::Readarr(active_readarr_block, _) => match active_readarr_block { + ActiveReadarrBlock::TestIndexer => { + if let Some(result) = app + .data + .readarr_data + .indexer_test_errors + .as_ref() + .filter(|_| !app.is_loading) + { + let popup = if !result.is_empty() { + Popup::new(Message::new(result.clone())).size(Size::LargeMessage) + } else { + let message = Message::new("Indexer test succeeded!") + .title("Success") + .style(success_style().bold()); + Popup::new(message).size(Size::Message) + }; + + f.render_widget(popup, f.area()); + } else { + let loading_popup = Popup::new(LoadingBlock::new( + app.is_loading || app.data.readarr_data.indexer_test_errors.is_none(), + title_block("Testing Indexer"), + )) + .size(Size::LargeMessage); + f.render_widget(loading_popup, f.area()); + } + } + ActiveReadarrBlock::DeleteIndexerPrompt => { + let prompt = format!( + "Do you really want to delete this indexer: \n{}?", + app + .data + .readarr_data + .indexers + .current_selection() + .name + .clone() + .unwrap_or_default() + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Indexer") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), + }, + _ => (), + } + } +} + +fn indexer_flag_text(flag: bool) -> Text<'static> { + if flag { + return Text::from("Enabled").success(); + } + + Text::from("Disabled").failure() +} + +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 rss = indexer_flag_text(*enable_rss); + let automatic_search = indexer_flag_text(*enable_automatic_search); + let interactive_search = indexer_flag_text(*enable_interactive_search); + let empty_tag = String::new(); + let tags: String = tags + .iter() + .map(|tag_id| { + app + .data + .readarr_data + .tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap_or(&empty_tag) + .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 = ManagarrTable::new( + Some(&mut app.data.readarr_data.indexers), + indexers_row_mapping, + ) + .block(layout_block_top_border()) + .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/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerApiKeyInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerApiKeyInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerApiKeyInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerConfirmPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerConfirmPrompt.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerConfirmPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerNameInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerNameInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerNameInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPriorityInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPriorityInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPriorityInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPrompt.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerSeedRatioInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerSeedRatioInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerSeedRatioInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerTagsInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerTagsInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerTagsInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableAutomaticSearch.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableAutomaticSearch.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableAutomaticSearch.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableInteractiveSearch.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableInteractiveSearch.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableInteractiveSearch.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableRss.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableRss.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableRss.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerUrlInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerUrlInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerUrlInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_loading.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_loading.snap new file mode 100644 index 00000000..75906c5e --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_loading.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_usenet_grid.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_usenet_grid.snap new file mode 100644 index 00000000..a0cbe6ca --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_usenet_grid.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Indexer Priority ▴▾: │1 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_AllIndexerSettingsPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_AllIndexerSettingsPrompt.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_AllIndexerSettingsPrompt.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsConfirmPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsConfirmPrompt.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsConfirmPrompt.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMaximumSizeInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMaximumSizeInput.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMaximumSizeInput.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMinimumAgeInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMinimumAgeInput.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMinimumAgeInput.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRetentionInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRetentionInput.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRetentionInput.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRssSyncIntervalInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRssSyncIntervalInput.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRssSyncIntervalInput.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_loading.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_loading.snap new file mode 100644 index 00000000..321e6a35 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_loading.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_DeleteIndexerPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_DeleteIndexerPrompt.snap new file mode 100644 index 00000000..98cd8000 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_DeleteIndexerPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + + + + ╭──────────────────── Delete Indexer ─────────────────────╮ + │ Do you really want to delete this indexer: │ + │ Decoy Usenet Indexer? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_Indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_Indexers.snap new file mode 100644 index 00000000..70e39648 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_Indexers.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_TestIndexer.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_TestIndexer.snap new file mode 100644 index 00000000..165a3765 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_TestIndexer.snap @@ -0,0 +1,35 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │ error │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_delete_prompt_for_non_default_selection.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_delete_prompt_for_non_default_selection.snap new file mode 100644 index 00000000..62f2d669 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_delete_prompt_for_non_default_selection.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags + Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only +=> Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + + + + ╭──────────────────── Delete Indexer ─────────────────────╮ + │ Do you really want to delete this indexer: │ + │ Test Torrent Indexer? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_torrent_indexer_over_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_torrent_indexer_over_indexers.snap new file mode 100644 index 00000000..b2d2bedf --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_torrent_indexer_over_indexers.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags + Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only +=> Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ ✔ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_usenet_indexer_over_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_usenet_indexer_over_indexers.snap new file mode 100644 index 00000000..18e13888 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_usenet_indexer_over_indexers.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Indexer Priority ▴▾: │1 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_empty_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_empty_indexers.snap new file mode 100644 index 00000000..20e686c5 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_empty_indexers.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap new file mode 100644 index 00000000..ff32ac41 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading.snap new file mode 100644 index 00000000..1c8ab1cd --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results.snap new file mode 100644 index 00000000..9bad23a3 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results.snap @@ -0,0 +1,35 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... + + + + + + + + + + + + + + + + ╭ Testing Indexer ────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results_when_indexer_test_errors_is_none.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results_when_indexer_test_errors_is_none.snap new file mode 100644 index 00000000..0d598c66 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results_when_indexer_test_errors_is_none.snap @@ -0,0 +1,35 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + + + + + + + ╭ Testing Indexer ────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_all_indexers_over_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_all_indexers_over_indexers.snap new file mode 100644 index 00000000..bacce06a --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_all_indexers_over_indexers.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Indexer Pass/Fail Failure Messages │ + │=> DrunkenSlug x Some failure │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_indexer_success_when_there_are_no_errors.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_indexer_success_when_there_are_no_errors.snap new file mode 100644 index 00000000..4999ebfe --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_indexer_success_when_there_are_no_errors.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + + + + + + + + + + + ╭────────────── Success ──────────────╮ + │ Indexer test succeeded! │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders.snap new file mode 100644 index 00000000..f1693108 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Indexer Pass/Fail Failure Messages │ + │=> Test Indexer + │ + │ Other Indexer x Failure for field 'baseUrl': Unable to connect to indexer, Failure for field 'apiKe│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_empty_results.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_empty_results.snap new file mode 100644 index 00000000..a719b4db --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_empty_results.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_for_non_default_selection.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_for_non_default_selection.snap new file mode 100644 index 00000000..d29cf306 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_for_non_default_selection.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Indexer Pass/Fail Failure Messages │ + │ Test Indexer + │ + │=> Other Indexer x Failure for field 'baseUrl': Unable to connect to indexer, Failure for field 'apiKe│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading.snap new file mode 100644 index 00000000..2f9c8657 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading_when_results_are_none.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading_when_results_are_none.snap new file mode 100644 index 00000000..2f9c8657 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading_when_results_are_none.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/readarr_ui/indexers/test_all_indexers_ui.rs new file mode 100644 index 00000000..d25919f2 --- /dev/null +++ b/src/ui/readarr_ui/indexers/test_all_indexers_ui.rs @@ -0,0 +1,86 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, title_block}; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; + +#[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::Readarr(active_readarr_block, _) = route { + return active_readarr_block == ActiveReadarrBlock::TestAllIndexers; + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup(f, app, draw_test_all_indexers_test_results, Size::Large); + } +} + +fn decorate_test_result_row_with_style<'a>( + result: &IndexerTestResultModalItem, + row: Row<'a>, +) -> Row<'a> { + if result.is_valid { + row.success() + } else { + row.failure() + } +} + +fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.readarr_data.indexer_test_all_results.is_none(); + let current_selection = if let Some(test_all_results) = + app.data.readarr_data.indexer_test_all_results.as_ref() + && !test_all_results.is_empty() + { + test_all_results.current_selection().clone() + } else { + IndexerTestResultModalItem::default() + }; + f.render_widget(title_block("Test All Indexers"), area); + let test_results_row_mapping = |result: &IndexerTestResultModalItem| { + result.validation_failures.scroll_left_or_reset( + get_width_from_percentage(area, 86), + *result == current_selection, + app.should_text_scroll, + ); + let pass_fail = if result.is_valid { "+" } else { "x" }; + let row = Row::new(vec![ + Cell::from(result.name.to_owned()), + Cell::from(pass_fail.to_owned()), + Cell::from(result.validation_failures.to_string()), + ]); + + decorate_test_result_row_with_style(result, row) + }; + + let indexers_test_results_table = ManagarrTable::new( + app.data.readarr_data.indexer_test_all_results.as_mut(), + test_results_row_mapping, + ) + .loading(is_loading) + .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/readarr_ui/indexers/test_all_indexers_ui_tests.rs b/src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs new file mode 100644 index 00000000..512bb2d8 --- /dev/null +++ b/src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs @@ -0,0 +1,156 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::indexers::test_all_indexers_ui::TestAllIndexersUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_test_all_indexers_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if active_readarr_block == ActiveReadarrBlock::TestAllIndexers { + assert!(TestAllIndexersUi::accepts(active_readarr_block.into())); + } else { + assert!(!TestAllIndexersUi::accepts(active_readarr_block.into())); + } + }); + } + + mod test_decorate_test_result_row_with_style { + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::ui::readarr_ui::indexers::test_all_indexers_ui::decorate_test_result_row_with_style; + use crate::ui::styles::ManagarrStyle; + + fn indexer_test_result(is_valid: bool) -> IndexerTestResultModalItem { + IndexerTestResultModalItem { + name: "Test Indexer".to_owned(), + is_valid, + validation_failures: "Unable to connect to indexer".into(), + } + } + + #[test] + fn test_decorate_test_result_row_with_style_when_the_result_is_valid() { + let row = Row::new(vec![Cell::from("Test Indexer")]); + + let decorated_row = + decorate_test_result_row_with_style(&indexer_test_result(true), row.clone()); + + assert_eq!(decorated_row, row.success()); + } + + #[test] + fn test_decorate_test_result_row_with_style_when_the_result_is_invalid() { + let row = Row::new(vec![Cell::from("Test Indexer")]); + + let decorated_row = + decorate_test_result_row_with_style(&indexer_test_result(false), row.clone()); + + assert_eq!(decorated_row, row.failure()); + } + + #[test] + fn test_decorate_test_result_row_with_style_styles_the_two_branches_differently() { + let row = Row::new(vec![Cell::from("Test Indexer")]); + + let decorated_row = + decorate_test_result_row_with_style(&indexer_test_result(true), row.clone()); + + assert_ne!(decorated_row, row.failure()); + } + } + + mod snapshot_tests { + use crate::models::Scrollable; + use crate::models::stateful_table::StatefulTable; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer_test_results; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + fn seed_distinct_test_results(app: &mut App<'_>) { + let mut indexer_test_all_results = StatefulTable::default(); + indexer_test_all_results.set_items(indexer_test_results()); + app.data.readarr_data.indexer_test_all_results = Some(indexer_test_all_results); + } + + #[test] + fn test_test_all_indexers_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + seed_distinct_test_results(&mut app); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_test_all_indexers_ui_renders_loading_when_results_are_none() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.indexer_test_all_results = None; + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_test_all_indexers_ui_renders_empty_results() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.indexer_test_all_results = Some(StatefulTable::default()); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_test_all_indexers_ui_renders() { + let mut app = App::test_default_fully_populated(); + seed_distinct_test_results(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_test_all_indexers_ui_renders_for_non_default_selection() { + let mut app = App::test_default_fully_populated(); + seed_distinct_test_results(&mut app); + app + .data + .readarr_data + .indexer_test_all_results + .as_mut() + .expect("indexer_test_all_results must exist in this context") + .scroll_down(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs index a1e68c53..3b6b7712 100644 --- a/src/ui/readarr_ui/mod.rs +++ b/src/ui/readarr_ui/mod.rs @@ -24,6 +24,7 @@ use super::{ }; use crate::ui::readarr_ui::blocklist::BlocklistUi; use crate::ui::readarr_ui::downloads::DownloadsUi; +use crate::ui::readarr_ui::indexers::IndexersUi; use crate::ui::readarr_ui::root_folders::RootFoldersUi; use crate::ui::readarr_ui::system::SystemUi; use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; @@ -42,6 +43,7 @@ use crate::{ mod blocklist; mod downloads; mod history; +mod indexers; mod library; mod readarr_ui_utils; mod root_folders; @@ -68,6 +70,7 @@ impl DrawUi for ReadarrUi { _ 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), _ if SystemUi::accepts(route) => SystemUi::draw(f, app, content_area), _ => (), } diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap index d3f01721..de20de5f 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap @@ -4,9 +4,9 @@ expression: output --- ╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ -│ │ -│ │ -│ │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Indexer RSS Automatic Search Interactive Search Priority Tags │ +│=> Test Indexer Enabled Enabled Enabled 25 alex │ │ │ │ │ │ │ From 720584ea1761d7875bca36a9a1ce5219d87f54bb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 15:56:38 -0600 Subject: [PATCH 101/170] fix: drop redundant references in format arguments The borrows were passed straight to format!, which already takes its arguments by reference. Clears the last of the compiler warnings. --- src/ui/lidarr_ui/library/album_details_ui.rs | 4 ++-- src/ui/lidarr_ui/library/artist_details_ui.rs | 4 ++-- src/ui/radarr_ui/library/movie_details_ui.rs | 4 ++-- src/ui/sonarr_ui/library/episode_details_ui.rs | 4 ++-- src/ui/sonarr_ui/library/season_details_ui.rs | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/ui/lidarr_ui/library/album_details_ui.rs b/src/ui/lidarr_ui/library/album_details_ui.rs index 86e7899b..66871a58 100644 --- a/src/ui/lidarr_ui/library/album_details_ui.rs +++ b/src/ui/lidarr_ui/library/album_details_ui.rs @@ -440,12 +440,12 @@ fn draw_manual_album_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; diff --git a/src/ui/lidarr_ui/library/artist_details_ui.rs b/src/ui/lidarr_ui/library/artist_details_ui.rs index 952bfc1b..3cabf7b6 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui.rs @@ -566,12 +566,12 @@ fn draw_manual_artist_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_> let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index c562cbfe..60bac887 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -487,12 +487,12 @@ fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; diff --git a/src/ui/sonarr_ui/library/episode_details_ui.rs b/src/ui/sonarr_ui/library/episode_details_ui.rs index aa69d564..ae939bc4 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui.rs @@ -537,12 +537,12 @@ fn draw_manual_episode_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_ let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index 92926a3e..1f968562 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -483,12 +483,12 @@ fn draw_manual_season_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_> let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; From 3fd0a6173770c452cc85f43ab264420cbef8ef96 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 16:07:19 -0600 Subject: [PATCH 102/170] fix: show confirmation prompt clues on delete prompts Delete confirmation prompts had no arm in any of the context clue providers, so they fell through to the main tab's contextual help and displayed the library tab's hints, such as add, sort and edit, while the user was looking at a delete confirmation. Map the delete block arrays in all four servarrs to the shared confirmation prompt clues, matching how the other prompts and selection modals are already wired. --- src/app/lidarr/lidarr_context_clues.rs | 9 +++-- src/app/lidarr/lidarr_context_clues_tests.rs | 36 +++++++++++++++++++ src/app/radarr/radarr_context_clues.rs | 7 ++-- src/app/radarr/radarr_context_clues_tests.rs | 19 ++++++++++ src/app/readarr/readarr_context_clues.rs | 9 +++-- .../readarr/readarr_context_clues_tests.rs | 36 +++++++++++++++++++ src/app/sonarr/sonarr_context_clues.rs | 8 +++-- src/app/sonarr/sonarr_context_clues_tests.rs | 18 ++++++++++ 8 files changed, 134 insertions(+), 8 deletions(-) diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index 2b734f4a..61da0c35 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -7,8 +7,8 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ARTIST_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, - ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, - TRACK_DETAILS_BLOCKS, + ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, + EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, TRACK_DETAILS_BLOCKS, }; #[cfg(test)] @@ -203,6 +203,11 @@ impl ContextClueProvider for LidarrContextClueProvider { { Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) } + _ if DELETE_ARTIST_BLOCKS.contains(&active_lidarr_block) + || DELETE_ALBUM_BLOCKS.contains(&active_lidarr_block) => + { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } ActiveLidarrBlock::AddArtistPrompt | ActiveLidarrBlock::AddArtistSelectMonitor | ActiveLidarrBlock::AddArtistSelectMonitorNewItems diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 92ef2e6f..57207974 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -577,6 +577,42 @@ mod tests { assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); } + #[rstest] + fn test_lidarr_context_clue_provider_confirmation_prompt_context_clues_delete_artist_blocks( + #[values( + ActiveLidarrBlock::DeleteArtistPrompt, + ActiveLidarrBlock::DeleteArtistConfirmPrompt, + ActiveLidarrBlock::DeleteArtistToggleAddListExclusion, + ActiveLidarrBlock::DeleteArtistToggleDeleteFile + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_lidarr_block.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + + #[rstest] + fn test_lidarr_context_clue_provider_confirmation_prompt_context_clues_delete_album_blocks( + #[values( + ActiveLidarrBlock::DeleteAlbumPrompt, + ActiveLidarrBlock::DeleteAlbumConfirmPrompt, + ActiveLidarrBlock::DeleteAlbumToggleAddListExclusion, + ActiveLidarrBlock::DeleteAlbumToggleDeleteFile + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_lidarr_block.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + #[test] fn test_lidarr_context_clue_provider_system_tasks_clues() { let mut app = App::test_default(); diff --git a/src/app/radarr/radarr_context_clues.rs b/src/app/radarr/radarr_context_clues.rs index 32221877..e5310f53 100644 --- a/src/app/radarr/radarr_context_clues.rs +++ b/src/app/radarr/radarr_context_clues.rs @@ -6,8 +6,8 @@ use crate::app::context_clues::{ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; use crate::models::servarr_data::radarr::radarr_data::{ - ADD_MOVIE_BLOCKS, ActiveRadarrBlock, EDIT_COLLECTION_BLOCKS, EDIT_INDEXER_BLOCKS, - EDIT_MOVIE_BLOCKS, INDEXER_SETTINGS_BLOCKS, MOVIE_DETAILS_BLOCKS, + ADD_MOVIE_BLOCKS, ActiveRadarrBlock, DELETE_MOVIE_BLOCKS, EDIT_COLLECTION_BLOCKS, + EDIT_INDEXER_BLOCKS, EDIT_MOVIE_BLOCKS, INDEXER_SETTINGS_BLOCKS, MOVIE_DETAILS_BLOCKS, }; #[cfg(test)] @@ -120,6 +120,9 @@ impl ContextClueProvider for RadarrContextClueProvider { { Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) } + _ if DELETE_MOVIE_BLOCKS.contains(&active_radarr_block) => { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } ActiveRadarrBlock::AddMoviePrompt | ActiveRadarrBlock::AddMovieSelectMonitor | ActiveRadarrBlock::AddMovieSelectMinimumAvailability diff --git a/src/app/radarr/radarr_context_clues_tests.rs b/src/app/radarr/radarr_context_clues_tests.rs index a4b4f10a..29ff2922 100644 --- a/src/app/radarr/radarr_context_clues_tests.rs +++ b/src/app/radarr/radarr_context_clues_tests.rs @@ -415,6 +415,25 @@ mod tests { assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); } + #[rstest] + fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_delete_movie_blocks( + #[values( + ActiveRadarrBlock::DeleteMoviePrompt, + ActiveRadarrBlock::DeleteMovieConfirmPrompt, + ActiveRadarrBlock::DeleteMovieToggleAddListExclusion, + ActiveRadarrBlock::DeleteMovieToggleDeleteFile + )] + active_radarr_block: ActiveRadarrBlock, + ) { + let mut app = App::test_default(); + app.data.radarr_data = RadarrData::default(); + app.push_navigation_stack(active_radarr_block.into()); + + let context_clues = RadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + #[rstest] fn test_radarr_context_clue_provider_add_movie_search_results_context_clues( #[values( diff --git a/src/app/readarr/readarr_context_clues.rs b/src/app/readarr/readarr_context_clues.rs index 442fabbb..9f742241 100644 --- a/src/app/readarr/readarr_context_clues.rs +++ b/src/app/readarr/readarr_context_clues.rs @@ -7,8 +7,8 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; use crate::models::servarr_data::readarr::readarr_data::{ ADD_AUTHOR_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, - BOOK_DETAILS_BLOCKS, EDIT_AUTHOR_BLOCKS, EDIT_INDEXER_BLOCKS, EDITION_DETAILS_BLOCKS, - INDEXER_SETTINGS_BLOCKS, + BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, + EDIT_INDEXER_BLOCKS, EDITION_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, }; #[cfg(test)] @@ -191,6 +191,11 @@ impl ContextClueProvider for ReadarrContextClueProvider { { Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) } + _ if DELETE_AUTHOR_BLOCKS.contains(&active_readarr_block) + || DELETE_BOOK_BLOCKS.contains(&active_readarr_block) => + { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } ActiveReadarrBlock::AddAuthorPrompt | ActiveReadarrBlock::AddAuthorSelectMonitor | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems diff --git a/src/app/readarr/readarr_context_clues_tests.rs b/src/app/readarr/readarr_context_clues_tests.rs index 76eb259b..76112630 100644 --- a/src/app/readarr/readarr_context_clues_tests.rs +++ b/src/app/readarr/readarr_context_clues_tests.rs @@ -548,6 +548,42 @@ mod tests { assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); } + #[rstest] + fn test_readarr_context_clue_provider_confirmation_prompt_context_clues_delete_author_blocks( + #[values( + ActiveReadarrBlock::DeleteAuthorPrompt, + ActiveReadarrBlock::DeleteAuthorConfirmPrompt, + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion, + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + + #[rstest] + fn test_readarr_context_clue_provider_confirmation_prompt_context_clues_delete_book_blocks( + #[values( + ActiveReadarrBlock::DeleteBookPrompt, + ActiveReadarrBlock::DeleteBookConfirmPrompt, + ActiveReadarrBlock::DeleteBookToggleAddListExclusion, + ActiveReadarrBlock::DeleteBookToggleDeleteFile + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + #[test] fn test_readarr_context_clue_provider_system_tasks_clues() { let mut app = App::test_default(); diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 90d8aada..fb5b46c6 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -5,8 +5,9 @@ use crate::app::context_clues::{ use crate::app::{App, context_clues::ContextClue, key_binding::DEFAULT_KEYBINDINGS}; use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ - ADD_SERIES_BLOCKS, ActiveSonarrBlock, EDIT_INDEXER_BLOCKS, EDIT_SERIES_BLOCKS, - EPISODE_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, + ADD_SERIES_BLOCKS, ActiveSonarrBlock, DELETE_SERIES_BLOCKS, EDIT_INDEXER_BLOCKS, + EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, SEASON_DETAILS_BLOCKS, + SERIES_DETAILS_BLOCKS, }; #[cfg(test)] @@ -207,6 +208,9 @@ impl ContextClueProvider for SonarrContextClueProvider { { Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) } + _ if DELETE_SERIES_BLOCKS.contains(&active_sonarr_block) => { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } ActiveSonarrBlock::AddSeriesPrompt | ActiveSonarrBlock::AddSeriesSelectMonitor | ActiveSonarrBlock::AddSeriesSelectSeriesType diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 4f02fc35..6f3e4322 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -603,6 +603,24 @@ mod tests { assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); } + #[rstest] + fn test_sonarr_context_clue_provider_confirmation_prompt_popup_clues_delete_series_blocks( + #[values( + ActiveSonarrBlock::DeleteSeriesPrompt, + ActiveSonarrBlock::DeleteSeriesConfirmPrompt, + ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion, + ActiveSonarrBlock::DeleteSeriesToggleDeleteFile + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_sonarr_block.into()); + + let context_clues = SonarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + #[rstest] fn test_sonarr_context_clue_provider_add_series_search_results_clues( #[values( From ee6035c75d2dc977744906677accad1c2a3d024f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 16:18:00 -0600 Subject: [PATCH 103/170] test(readarr): cover the top level UI dispatch for Readarr The top level ui() dispatch had no Readarr coverage, so removing its match arm let every test in the suite pass while the Readarr views rendered nothing at all. Mirrors the Radarr set, which is the most complete of the three: it is the only one that also covers the notification failure popup. --- ...tests__readarr_ui_renders_library_tab.snap | 54 ++++++++++++++ ...rr_ui_renders_library_tab_error_popup.snap | 54 ++++++++++++++ ...arr_ui_renders_library_tab_with_error.snap | 54 ++++++++++++++ ...ui_renders_notification_failure_popup.snap | 54 ++++++++++++++ ...ui_renders_notification_success_popup.snap | 54 ++++++++++++++ src/ui/ui_tests.rs | 73 +++++++++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap create mode 100644 src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap create mode 100644 src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap create mode 100644 src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap create mode 100644 src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap new file mode 100644 index 00000000..46d16cac --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap new file mode 100644 index 00000000..af4527c1 --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 │ Key Alt Key Description │━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: │=> a add │ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━│ e edit │ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: │ m toggle monitoring │ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free │ o sort │ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ │ del delete │ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ │ s search │ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────│ f filter │─────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────│ ctrl-r refresh │─────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ │ u update all │ │ +│───────────────────────────────────│ enter details │─────────────────────────────────────│ +│ Name ▼ │ esc cancel filter │e Monitored Tags │ +│=> Test Author │ ↑ k scroll up │0 GB 🏷 alex │ +│ │ ↓ j scroll down │ │ +│ │ ← h previous tab │ │ +│ │ → l next tab │ │ +│ │ pgUp ctrl-u page up │ │ +│ │ pgDown ctrl-d page down │ │ +│ │ tab next servarr │ │ +│ │ shift-tab previous servarr │ │ +│ │ q quit │ │ +│ │ ? show/hide keybindings │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap new file mode 100644 index 00000000..e32ecd57 --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Error | to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Some error │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap new file mode 100644 index 00000000..5b19748e --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭────────── Download Failed ──────────╮ │ +│ │ Request failed. Received 500 response │ │ +│ │ code │ │ +│ ╰───────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap new file mode 100644 index 00000000..0fe7bc59 --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭────────── Download Result ──────────╮ │ +│ │ Download request sent successfully │ │ +│ │ │ │ +│ ╰───────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/ui_tests.rs b/src/ui/ui_tests.rs index 555e0ba7..adff0a8f 100644 --- a/src/ui/ui_tests.rs +++ b/src/ui/ui_tests.rs @@ -5,6 +5,7 @@ mod snapshot_tests { use crate::models::servarr_data::Notification; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::ui; use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; @@ -190,4 +191,76 @@ mod snapshot_tests { insta::assert_snapshot!(output); } + + #[test] + fn test_readarr_ui_renders_library_tab() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_library_tab_with_error() { + let mut app = App::test_default_fully_populated(); + app.error = "Some error".into(); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_library_tab_error_popup() { + let mut app = App::test_default_fully_populated(); + populate_keymapping_table(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_notification_success_popup() { + let mut app = App::test_default_fully_populated(); + app.notification = Some(Notification::new( + "Download Result".to_owned(), + "Download request sent successfully".to_owned(), + true, + )); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_notification_failure_popup() { + let mut app = App::test_default_fully_populated(); + app.notification = Some(Notification::new( + "Download Failed".to_owned(), + "Request failed. Received 500 response code".to_owned(), + false, + )); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } } From 27c458307fdb47be959e5d57a4edbdd666eafe2e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 18:05:52 -0600 Subject: [PATCH 104/170] feat(readarr): add a scrollable overview popup to author details An author's overview renders as the last line of a description box that does not scroll, so a long overview overflows with no way to read the rest. Pressing v on the author details Books tab now opens a popup holding the full overview. The popup owns its own ScrollableText, built fresh from the author's overview when it opens and torn down on Esc so a reopened popup starts at the top. Scrolling mutates only that owned state. The background paragraph renders the Author record's overview, which carries no scroll offset, so it cannot scroll along with the popup. Add a view keybinding bound to v, cite it from the author details context clues, and give the popup its own clue set so it does not inherit the hints of the view underneath. --- src/app/key_binding.rs | 6 + src/app/key_binding_tests.rs | 1 + src/app/readarr/readarr_context_clues.rs | 20 +- .../readarr/readarr_context_clues_tests.rs | 40 +- .../library/author_details_handler.rs | 38 +- .../library/author_details_handler_tests.rs | 175 +++++++- .../library/author_overview_handler.rs | 111 +++++ .../library/author_overview_handler_tests.rs | 393 ++++++++++++++++++ .../library/library_handler_tests.rs | 7 +- src/handlers/readarr_handlers/library/mod.rs | 1 + src/models/servarr_data/readarr/modals.rs | 6 + .../servarr_data/readarr/modals_tests.rs | 11 +- .../servarr_data/readarr/readarr_data.rs | 23 +- .../readarr/readarr_data_tests.rs | 27 +- .../readarr_ui/library/author_details_ui.rs | 6 + .../library/author_details_ui_tests.rs | 46 +- .../readarr_ui/library/author_overview_ui.rs | 87 ++++ .../library/author_overview_ui_tests.rs | 205 +++++++++ src/ui/readarr_ui/library/library_ui_tests.rs | 7 +- src/ui/readarr_ui/library/mod.rs | 1 + ...tests__author_details_AuthorDetails_0.snap | 4 +- ...author_details_AuthorHistoryDetails_1.snap | 4 +- ...hor_details_AuthorHistorySortPrompt_1.snap | 4 +- ...tests__author_details_AuthorHistory_1.snap | 4 +- ...ils_AutomaticallySearchAuthorPrompt_0.snap | 4 +- ...ils_AutomaticallySearchAuthorPrompt_1.snap | 4 +- ...ils_AutomaticallySearchAuthorPrompt_2.snap | 4 +- ...or_details_FilterAuthorHistoryError_1.snap | 4 +- ..._author_details_FilterAuthorHistory_1.snap | 4 +- ...ils_ManualAuthorSearchConfirmPrompt_2.snap | 4 +- ...etails_ManualAuthorSearchSortPrompt_2.snap | 4 +- ...__author_details_ManualAuthorSearch_2.snap | 4 +- ...or_details_SearchAuthorHistoryError_1.snap | 4 +- ..._author_details_SearchAuthorHistory_1.snap | 4 +- ...ts__author_details_SearchBooksError_0.snap | 4 +- ...t_tests__author_details_SearchBooks_0.snap | 4 +- ...r_details_UpdateAndScanAuthorPrompt_0.snap | 4 +- ...r_details_UpdateAndScanAuthorPrompt_1.snap | 4 +- ...r_details_UpdateAndScanAuthorPrompt_2.snap | 4 +- ...s_author_overview_over_author_details.snap | 52 +++ ...tic_search_prompt_over_author_details.snap | 4 +- ...ders_book_details_over_author_details.snap | 4 +- ...nders_delete_book_over_author_details.snap | 4 +- ..._author_details_ui_renders_statistics.snap | 2 +- ...e_and_scan_prompt_over_author_details.snap | 4 +- ...s__empty_author_details_AuthorDetails.snap | 4 +- ...s__empty_author_details_AuthorHistory.snap | 4 +- ...y_author_details_AuthorHistoryDetails.snap | 4 +- ...pty_author_details_ManualAuthorSearch.snap | 4 +- ..._loading_author_details_AuthorDetails.snap | 4 +- ..._loading_author_details_AuthorHistory.snap | 4 +- ...ing_author_details_ManualAuthorSearch.snap | 4 +- ...hot_tests__author_overview_ui_renders.snap | 48 +++ ...ui_renders_an_author_with_no_overview.snap | 48 +++ ...s__author_overview_ui_renders_loading.snap | 48 +++ ...he_author_overview_modal_is_undefined.snap | 5 + ...__author_overview_ui_renders_scrolled.snap | 48 +++ ...author_ui_renders_over_author_details.snap | 4 +- ...i_renders_author_details_over_library.snap | 4 +- 59 files changed, 1493 insertions(+), 97 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/author_overview_handler.rs create mode 100644 src/handlers/readarr_handlers/library/author_overview_handler_tests.rs create mode 100644 src/ui/readarr_ui/library/author_overview_ui.rs create mode 100644 src/ui/readarr_ui/library/author_overview_ui_tests.rs create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_an_author_with_no_overview.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_loading.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_nothing_when_the_author_overview_modal_is_undefined.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_scrolled.snap diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index d63f9e85..6ddbf1b8 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -27,6 +27,7 @@ generate_keybindings! { filter, sort, edit, + view, logs, tasks, test, @@ -142,6 +143,11 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { alt: None, desc: "edit", }, + view: KeyBinding { + key: Key::Char('v'), + alt: None, + desc: "view", + }, events: KeyBinding { key: Key::Char('e'), alt: None, diff --git a/src/app/key_binding_tests.rs b/src/app/key_binding_tests.rs index 7db65011..7186305a 100644 --- a/src/app/key_binding_tests.rs +++ b/src/app/key_binding_tests.rs @@ -26,6 +26,7 @@ mod test { #[case(DEFAULT_KEYBINDINGS.filter, Key::Char('f'), None, "filter")] #[case(DEFAULT_KEYBINDINGS.sort, Key::Char('o'), None, "sort")] #[case(DEFAULT_KEYBINDINGS.edit, Key::Char('e'), None, "edit")] + #[case(DEFAULT_KEYBINDINGS.view, Key::Char('v'), None, "view")] #[case(DEFAULT_KEYBINDINGS.events, Key::Char('e'), None, "events")] #[case(DEFAULT_KEYBINDINGS.logs, Key::Char('L'), None, "logs")] #[case(DEFAULT_KEYBINDINGS.tasks, Key::Char('t'), None, "tasks")] diff --git a/src/app/readarr/readarr_context_clues.rs b/src/app/readarr/readarr_context_clues.rs index 9f742241..25b1d179 100644 --- a/src/app/readarr/readarr_context_clues.rs +++ b/src/app/readarr/readarr_context_clues.rs @@ -6,9 +6,9 @@ use crate::app::context_clues::{ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; use crate::models::servarr_data::readarr::readarr_data::{ - ADD_AUTHOR_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, - BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, - EDIT_INDEXER_BLOCKS, EDITION_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, + ADD_AUTHOR_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, + ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, + EDIT_AUTHOR_BLOCKS, EDIT_INDEXER_BLOCKS, EDITION_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, }; #[cfg(test)] @@ -39,7 +39,7 @@ pub static ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.esc, "edit search"), ]; -pub static AUTHOR_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ +pub static AUTHOR_DETAILS_CONTEXT_CLUES: [ContextClue; 9] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, @@ -56,6 +56,15 @@ pub static AUTHOR_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ DEFAULT_KEYBINDINGS.auto_search, DEFAULT_KEYBINDINGS.auto_search.desc, ), + (DEFAULT_KEYBINDINGS.view, "view overview"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static AUTHOR_OVERVIEW_CONTEXT_CLUES: [ContextClue; 2] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; @@ -168,6 +177,9 @@ impl ContextClueProvider for ReadarrContextClueProvider { .readarr_data .author_info_tabs .get_active_route_contextual_help(), + _ if AUTHOR_OVERVIEW_BLOCKS.contains(&active_readarr_block) => { + Some(&AUTHOR_OVERVIEW_CONTEXT_CLUES) + } _ if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) => app .data .readarr_data diff --git a/src/app/readarr/readarr_context_clues_tests.rs b/src/app/readarr/readarr_context_clues_tests.rs index 76112630..f9020633 100644 --- a/src/app/readarr/readarr_context_clues_tests.rs +++ b/src/app/readarr/readarr_context_clues_tests.rs @@ -8,10 +8,10 @@ mod tests { use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::readarr::readarr_context_clues::{ ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES, AUTHOR_DETAILS_CONTEXT_CLUES, - AUTHOR_HISTORY_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES, BOOK_DETAILS_CONTEXT_CLUES, - BOOK_FILE_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, EDITION_DETAILS_CONTEXT_CLUES, - MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, MANUAL_BOOK_SEARCH_CONTEXT_CLUES, - ReadarrContextClueProvider, + AUTHOR_HISTORY_CONTEXT_CLUES, AUTHOR_OVERVIEW_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES, + BOOK_DETAILS_CONTEXT_CLUES, BOOK_FILE_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, + EDITION_DETAILS_CONTEXT_CLUES, MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, + MANUAL_BOOK_SEARCH_CONTEXT_CLUES, ReadarrContextClueProvider, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::readarr::modals::BookDetailsModal; @@ -115,6 +115,10 @@ mod tests { DEFAULT_KEYBINDINGS.auto_search.desc, ) ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.view, "view overview") + ); assert_some_eq_x!( author_details_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) @@ -122,6 +126,24 @@ mod tests { assert_none!(author_details_context_clues_iter.next()); } + #[test] + fn test_author_overview_context_clues() { + let mut author_overview_context_clues_iter = AUTHOR_OVERVIEW_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + author_overview_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + author_overview_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(author_overview_context_clues_iter.next()); + } + #[test] fn test_add_author_search_results_context_clues() { let mut add_author_search_results_context_clues_iter = @@ -440,6 +462,16 @@ mod tests { assert_some_eq_x!(context_clues, &EDITION_DETAILS_CONTEXT_CLUES); } + #[test] + fn test_readarr_context_clue_provider_author_overview_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHOR_OVERVIEW_CONTEXT_CLUES); + } + #[test] fn test_readarr_context_clue_provider_authors_block() { let mut app = App::test_default(); diff --git a/src/handlers/readarr_handlers/library/author_details_handler.rs b/src/handlers/readarr_handlers/library/author_details_handler.rs index 26fa82a8..38a4db60 100644 --- a/src/handlers/readarr_handlers/library/author_details_handler.rs +++ b/src/handlers/readarr_handlers/library/author_details_handler.rs @@ -1,3 +1,4 @@ +use super::author_overview_handler::AuthorOverviewHandler; use crate::app::App; use crate::event::Key; use crate::handlers::readarr_handlers::history::history_sorting_options; @@ -5,13 +6,14 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::modals::AuthorOverviewModal; use crate::models::servarr_data::readarr::readarr_data::{ AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, DELETE_BOOK_SELECTION_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, }; use crate::models::servarr_models::ReleaseDownloadBody; use crate::models::stateful_table::SortOption; -use crate::models::{BlockSelectionState, Route}; +use crate::models::{BlockSelectionState, Route, ScrollableText}; use crate::network::readarr_network::ReadarrEvent; use serde_json::Number; @@ -34,10 +36,36 @@ impl AuthorDetailsHandler<'_, '_> { fn extract_book_id(&self) -> i64 { self.app.data.readarr_data.books.current_selection().id } + + fn build_author_overview_modal(&mut self) { + let overview = self + .app + .data + .readarr_data + .authors + .current_selection() + .overview + .clone() + .unwrap_or_default(); + + self.app.data.readarr_data.author_overview_modal = Some(AuthorOverviewModal { + overview: ScrollableText::with_string(overview), + }); + } } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorDetailsHandler<'a, 'b> { fn handle(&mut self) { + if AuthorOverviewHandler::accepts(self.active_readarr_block) { + return AuthorOverviewHandler::new( + self.key, + self.app, + self.active_readarr_block, + self._context, + ) + .handle(); + } + let books_table_handling_config = TableHandlingConfig::new(ActiveReadarrBlock::AuthorDetails.into()) .searching_block(ActiveReadarrBlock::SearchBooks.into()) @@ -78,7 +106,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorDetailsHandle } fn accepts(active_block: ActiveReadarrBlock) -> bool { - AUTHOR_DETAILS_BLOCKS.contains(&active_block) + AuthorOverviewHandler::accepts(active_block) || AUTHOR_DETAILS_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { @@ -294,6 +322,12 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorDetailsHandle self.app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); } + _ if matches_key!(view, key) => { + self.build_author_overview_modal(); + self + .app + .push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + } _ if matches_key!(toggle_monitoring, key) && !self.app.data.readarr_data.books.is_empty() => { diff --git a/src/handlers/readarr_handlers/library/author_details_handler_tests.rs b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs index aa028a8f..180aab68 100644 --- a/src/handlers/readarr_handlers/library/author_details_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs @@ -15,9 +15,10 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{ReadarrHistoryItem, ReadarrRelease}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, + AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, }; use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::test_handler_delegation; mod test_handle_delete { use super::*; @@ -465,6 +466,7 @@ mod tests { use crate::assert_navigation_pushed; use crate::handlers::KeyEventHandler; use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::handlers::readarr_handlers::library::author_overview_handler::AuthorOverviewHandler; use crate::models::readarr_models::Author; use crate::models::servarr_data::readarr::readarr_data::{ ActiveReadarrBlock, EDIT_AUTHOR_SELECTION_BLOCKS, @@ -473,7 +475,7 @@ mod tests { use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::torrent_release; use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; #[rstest] @@ -538,6 +540,161 @@ mod tests { assert_modal_absent!(app.data.readarr_data.edit_author_modal); } + #[test] + fn test_author_details_view_key_opens_the_author_overview() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorOverview.into()); + assert_modal_present!(app.data.readarr_data.author_overview_modal); + } + + #[test] + fn test_author_details_view_key_builds_the_overview_from_the_selected_author() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + let overview = &app + .data + .readarr_data + .author_overview_modal + .as_ref() + .unwrap() + .overview; + assert_eq!(overview.offset, 0); + assert_eq!( + overview.get_text(), + app + .data + .readarr_data + .authors + .current_selection() + .overview + .clone() + .unwrap() + ); + } + + #[test] + fn test_author_details_view_key_for_an_author_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + let mut author = app.data.readarr_data.authors.current_selection().clone(); + author.overview = None; + app.data.readarr_data.authors.set_items(vec![author]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorOverview.into()); + assert_str_eq!( + app + .data + .readarr_data + .author_overview_modal + .as_ref() + .unwrap() + .overview + .get_text(), + "" + ); + } + + #[test] + fn test_author_details_view_key_reopens_the_author_overview_at_the_top() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorOverview.into()); + assert_eq!( + app + .data + .readarr_data + .author_overview_modal + .as_ref() + .unwrap() + .overview + .offset, + 0 + ); + } + + #[test] + fn test_author_details_view_key_no_op_when_not_ready() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorDetails.into() + ); + assert_modal_absent!(app.data.readarr_data.author_overview_modal); + } + #[test] fn test_author_details_toggle_monitoring_key() { let mut app = App::test_default_fully_populated(); @@ -828,8 +985,11 @@ mod tests { #[test] fn test_author_details_handler_accepts() { + let mut author_details_blocks = AUTHOR_DETAILS_BLOCKS.to_vec(); + author_details_blocks.extend(AUTHOR_OVERVIEW_BLOCKS); + ActiveReadarrBlock::iter().for_each(|readarr_block| { - if AUTHOR_DETAILS_BLOCKS.contains(&readarr_block) { + if author_details_blocks.contains(&readarr_block) { assert!( AuthorDetailsHandler::accepts(readarr_block), "{readarr_block} is not accepted by the AuthorDetailsHandler" @@ -840,6 +1000,15 @@ mod tests { }); } + #[test] + fn test_delegates_author_overview_blocks_to_author_overview_handler() { + test_handler_delegation!( + AuthorDetailsHandler, + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorOverview + ); + } + #[test] fn test_extract_author_id() { let mut app = App::test_default_fully_populated(); diff --git a/src/handlers/readarr_handlers/library/author_overview_handler.rs b/src/handlers/readarr_handlers/library/author_overview_handler.rs new file mode 100644 index 00000000..5f404972 --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_overview_handler.rs @@ -0,0 +1,111 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::matches_key; +use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, +}; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "author_overview_handler_tests.rs"] +mod author_overview_handler_tests; + +pub(super) struct AuthorOverviewHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl AuthorOverviewHandler<'_, '_> { + fn overview(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .readarr_data + .author_overview_modal + .as_mut() + .expect("Author overview modal is undefined") + .overview + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorOverviewHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + AUTHOR_OVERVIEW_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> AuthorOverviewHandler<'a, 'b> { + AuthorOverviewHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + self.app.data.readarr_data.author_overview_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.overview().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.overview().scroll_down(); + } + + fn handle_home(&mut self) { + self.overview().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.overview().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) { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.author_overview_modal = None; + } + + fn handle_char_key_event(&mut self) { + if matches_key!(refresh, self.key) { + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/author_overview_handler_tests.rs b/src/handlers/readarr_handlers/library/author_overview_handler_tests.rs new file mode 100644 index 00000000..107598f8 --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_overview_handler_tests.rs @@ -0,0 +1,393 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_overview_handler::AuthorOverviewHandler; + use crate::models::ScrollableText; + use crate::models::servarr_data::readarr::modals::AuthorOverviewModal; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, + }; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_author_overview_scroll_up() { + let mut app = app_with_author_overview(); + set_offset(&mut app, 2); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_author_overview_scroll_up_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + set_offset(&mut app, 2); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 2); + } + + #[test] + fn test_author_overview_scroll_down() { + let mut app = app_with_author_overview(); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_author_overview_scroll_down_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_author_overview_home() { + let mut app = app_with_author_overview(); + set_offset(&mut app, 3); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + + #[test] + fn test_author_overview_end() { + let mut app = app_with_author_overview(); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + } + + #[test] + fn test_author_overview_home_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + set_offset(&mut app, 3); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 3); + } + + #[test] + fn test_author_overview_end_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_navigation_popped}; + + #[test] + fn test_author_overview_esc_tears_down_the_author_overview_modal() { + let mut app = app_with_author_overview(); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.author_overview_modal); + } + + #[test] + fn test_author_overview_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_author_overview(); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.author_overview_modal); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_author_overview_refresh_key() { + let mut app = app_with_author_overview(); + app.is_routing = false; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorOverview.into()); + assert!(app.is_routing); + } + + #[test] + fn test_author_overview_refresh_key_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + app.is_loading = true; + app.is_routing = false; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert!(!app.is_routing); + } + } + + #[test] + fn test_scrolling_the_overview_does_not_mutate_the_author_record() { + let mut app = app_with_author_overview(); + let overview_before = app + .data + .readarr_data + .authors + .current_selection() + .overview + .clone(); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + assert_eq!( + app.data.readarr_data.authors.current_selection().overview, + overview_before + ); + } + + #[test] + fn test_author_overview_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if AUTHOR_OVERVIEW_BLOCKS.contains(&active_readarr_block) { + assert!( + AuthorOverviewHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the AuthorOverviewHandler" + ); + } else { + assert!( + !AuthorOverviewHandler::accepts(active_readarr_block), + "{active_readarr_block} is wrongly accepted by the AuthorOverviewHandler" + ); + } + }); + } + + #[test] + fn test_author_overview_handler_dispatches_no_network_events() { + let mut app = app_with_author_overview(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + AuthorOverviewHandler::new(key, &mut app, ActiveReadarrBlock::AuthorOverview, None).handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + #[test] + fn test_author_overview_handler_is_not_ready_when_loading() { + let mut app = app_with_author_overview(); + app.is_loading = true; + + let handler = AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_overview_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = app_with_author_overview(); + app.data.readarr_data.author_overview_modal = None; + + let handler = AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_overview_handler_is_ready() { + let mut app = app_with_author_overview(); + + let handler = AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_author_overview_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_author_overview(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_author_overview() -> App<'static> { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = Some(AuthorOverviewModal { + overview: ScrollableText::with_string( + "some interesting description of the author\r\n\ + \r\n\ + He was born in Madison, Wisconsin: a city he has never really left.\r\n\ + \r\n\ + His first novel took him seven years to finish.\r\n" + .to_owned(), + ), + }); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + app + } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .readarr_data + .author_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .readarr_data + .author_overview_modal + .as_mut() + .unwrap() + .overview + .offset = offset; + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index 5f215732..ebd1f1dd 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -14,9 +14,9 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ - ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, - DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, - LIBRARY_BLOCKS, + ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, + BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, + EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; use crate::test_handler_delegation; @@ -931,6 +931,7 @@ mod tests { let mut library_handler_blocks = Vec::new(); library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS); + library_handler_blocks.extend(AUTHOR_OVERVIEW_BLOCKS); library_handler_blocks.extend(BOOK_DETAILS_BLOCKS); library_handler_blocks.extend(EDITION_DETAILS_BLOCKS); library_handler_blocks.extend(ADD_AUTHOR_BLOCKS); diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index cfc03e5f..8df9d253 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -21,6 +21,7 @@ use crate::models::Route; mod add_author_handler; mod author_details_handler; +mod author_overview_handler; mod book_details_handler; mod delete_author_handler; mod delete_book_handler; diff --git a/src/models/servarr_data/readarr/modals.rs b/src/models/servarr_data/readarr/modals.rs index b696796d..c9ff5cf4 100644 --- a/src/models/servarr_data/readarr/modals.rs +++ b/src/models/servarr_data/readarr/modals.rs @@ -276,3 +276,9 @@ impl Default for BookDetailsModal { pub struct EditionDetailsModal { pub edition_details: ScrollableText, } + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct AuthorOverviewModal { + pub overview: ScrollableText, +} diff --git a/src/models/servarr_data/readarr/modals_tests.rs b/src/models/servarr_data/readarr/modals_tests.rs index c6695a15..f790ac53 100644 --- a/src/models/servarr_data/readarr/modals_tests.rs +++ b/src/models/servarr_data/readarr/modals_tests.rs @@ -7,8 +7,8 @@ mod tests { use crate::models::readarr_models::{Author, MonitorType, NewItemMonitorType}; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::readarr::modals::{ - AddAuthorModal, AddReadarrRootFolderModal, BookDetailsModal, EditAuthorModal, - EditionDetailsModal, + AddAuthorModal, AddReadarrRootFolderModal, AuthorOverviewModal, BookDetailsModal, + EditAuthorModal, EditionDetailsModal, }; use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, ReadarrData}; use crate::models::servarr_models::{Indexer, IndexerField, RootFolder}; @@ -331,4 +331,11 @@ mod tests { assert_is_empty!(edition_details_modal.edition_details); } + + #[test] + fn test_author_overview_modal_default() { + let author_overview_modal = AuthorOverviewModal::default(); + + assert_is_empty!(author_overview_modal.overview); + } } diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index 27a37483..4162aa05 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -1,6 +1,8 @@ use serde_json::Number; -use super::modals::{AddAuthorModal, AddReadarrRootFolderModal, BookDetailsModal, EditAuthorModal}; +use super::modals::{ + AddAuthorModal, AddReadarrRootFolderModal, AuthorOverviewModal, BookDetailsModal, EditAuthorModal, +}; use crate::app::context_clues::{ BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, @@ -55,6 +57,7 @@ pub struct ReadarrData<'a> { pub add_searched_authors: Option>, pub author_history: StatefulTable, pub author_info_tabs: TabState, + pub author_overview_modal: Option, pub author_releases: StatefulTable, pub authors: StatefulTable, pub blocklist: StatefulTable, @@ -141,6 +144,7 @@ impl<'a> Default for ReadarrData<'a> { add_root_folder_modal: None, add_searched_authors: None, author_history: StatefulTable::default(), + author_overview_modal: None, author_releases: StatefulTable::default(), authors: StatefulTable::default(), blocklist: StatefulTable::default(), @@ -262,7 +266,14 @@ impl ReadarrData<'_> { author_name: "Test Author".into(), foreign_author_id: "test-foreign-id".to_owned(), status: AuthorStatus::Continuing, - overview: Some("some interesting description of the author".to_owned()), + overview: Some( + "some interesting description of the author\r\n\ + \r\n\ + He was born in Madison, Wisconsin: a city he has never really left.\r\n\ + \r\n\ + His first novel took him seven years to finish.\r\n" + .to_owned(), + ), path: "/nfs/books/Test Author".to_owned(), quality_profile_id: 1, metadata_profile_id: 1, @@ -551,9 +562,14 @@ impl ReadarrData<'_> { let mut indexer_test_all_results = StatefulTable::default(); indexer_test_all_results.set_items(vec![indexer_test_result()]); + let author_overview_modal = AuthorOverviewModal { + overview: ScrollableText::with_string(author.overview.clone().unwrap_or_default()), + }; + let mut readarr_data = ReadarrData { add_author_modal: Some(add_author_modal), add_root_folder_modal: Some(add_root_folder_modal), + author_overview_modal: Some(author_overview_modal), book_details_modal: Some(book_details_modal), delete_files: true, disk_space_vec: vec![diskspace()], @@ -647,6 +663,7 @@ pub enum ActiveReadarrBlock { AuthorHistory, AuthorHistoryDetails, AuthorHistorySortPrompt, + AuthorOverview, AuthorsSortPrompt, AutomaticallySearchAuthorPrompt, AutomaticallySearchBookPrompt, @@ -770,6 +787,8 @@ pub static AUTHOR_DETAILS_BLOCKS: [ActiveReadarrBlock; 15] = [ ActiveReadarrBlock::UpdateAndScanAuthorPrompt, ]; +pub static AUTHOR_OVERVIEW_BLOCKS: [ActiveReadarrBlock; 1] = [ActiveReadarrBlock::AuthorOverview]; + pub static BOOK_DETAILS_BLOCKS: [ActiveReadarrBlock; 16] = [ ActiveReadarrBlock::BookDetails, ActiveReadarrBlock::BookHistory, diff --git a/src/models/servarr_data/readarr/readarr_data_tests.rs b/src/models/servarr_data/readarr/readarr_data_tests.rs index 548059f8..679dc605 100644 --- a/src/models/servarr_data/readarr/readarr_data_tests.rs +++ b/src/models/servarr_data/readarr/readarr_data_tests.rs @@ -11,13 +11,14 @@ mod tests { use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; use crate::models::servarr_data::readarr::readarr_data::{ ADD_AUTHOR_BLOCKS, ADD_AUTHOR_SELECTION_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, - ADD_ROOT_FOLDER_SELECTION_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BLOCKLIST_BLOCKS, - BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_AUTHOR_SELECTION_BLOCKS, DELETE_BOOK_BLOCKS, - DELETE_BOOK_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_AUTHOR_BLOCKS, - EDIT_AUTHOR_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_INDEXER_NZB_SELECTION_BLOCKS, - EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDITION_DETAILS_BLOCKS, HISTORY_BLOCKS, - INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, LIBRARY_BLOCKS, - ROOT_FOLDERS_BLOCKS, ReadarrData, SYSTEM_DETAILS_BLOCKS, + ADD_ROOT_FOLDER_SELECTION_BLOCKS, AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, + ActiveReadarrBlock, BLOCKLIST_BLOCKS, BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, + DELETE_AUTHOR_SELECTION_BLOCKS, DELETE_BOOK_BLOCKS, DELETE_BOOK_SELECTION_BLOCKS, + DOWNLOADS_BLOCKS, EDIT_AUTHOR_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + EDITION_DETAILS_BLOCKS, HISTORY_BLOCKS, INDEXER_SETTINGS_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, + ReadarrData, SYSTEM_DETAILS_BLOCKS, }; use crate::models::{BlockSelectionState, Route}; use bimap::BiMap; @@ -152,6 +153,7 @@ mod tests { assert_none!(readarr_data.add_root_folder_modal); assert_none!(readarr_data.add_searched_authors); assert_is_empty!(readarr_data.author_history); + assert_none!(readarr_data.author_overview_modal); assert_is_empty!(readarr_data.author_releases); assert_is_empty!(readarr_data.authors); assert_is_empty!(readarr_data.blocklist); @@ -361,6 +363,17 @@ mod tests { assert!(EDITION_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookEditionDetails)); } + #[test] + fn test_author_details_blocks_excludes_author_overview() { + assert!(!AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorOverview)); + } + + #[test] + fn test_author_overview_blocks_contents() { + assert_eq!(AUTHOR_OVERVIEW_BLOCKS.len(), 1); + assert!(AUTHOR_OVERVIEW_BLOCKS.contains(&ActiveReadarrBlock::AuthorOverview)); + } + #[test] fn test_blocklist_blocks_contents() { assert_eq!(BLOCKLIST_BLOCKS.len(), 5); diff --git a/src/ui/readarr_ui/library/author_details_ui.rs b/src/ui/readarr_ui/library/author_details_ui.rs index f93fd8aa..4bf12992 100644 --- a/src/ui/readarr_ui/library/author_details_ui.rs +++ b/src/ui/readarr_ui/library/author_details_ui.rs @@ -13,6 +13,7 @@ use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; use crate::models::servarr_data::readarr::readarr_data::{ AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, }; +use crate::ui::readarr_ui::library::author_overview_ui::AuthorOverviewUi; use crate::ui::readarr_ui::library::book_details_ui::BookDetailsUi; use crate::ui::readarr_ui::library::delete_book_ui::DeleteBookUi; use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; @@ -45,6 +46,7 @@ impl DrawUi for AuthorDetailsUi { }; BookDetailsUi::accepts(route) || DeleteBookUi::accepts(route) + || AuthorOverviewUi::accepts(route) || AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) } @@ -134,6 +136,10 @@ impl DrawUi for AuthorDetailsUi { if BookDetailsUi::accepts(route) { BookDetailsUi::draw(f, app, _area); } + + if AuthorOverviewUi::accepts(route) { + AuthorOverviewUi::draw(f, app, _area); + } } } } diff --git a/src/ui/readarr_ui/library/author_details_ui_tests.rs b/src/ui/readarr_ui/library/author_details_ui_tests.rs index ae1befa3..e288f7a6 100644 --- a/src/ui/readarr_ui/library/author_details_ui_tests.rs +++ b/src/ui/readarr_ui/library/author_details_ui_tests.rs @@ -7,8 +7,8 @@ mod tests { use crate::models::readarr_models::{Book, BookStatistics}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, DELETE_BOOK_BLOCKS, - EDITION_DETAILS_BLOCKS, + AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + DELETE_BOOK_BLOCKS, EDITION_DETAILS_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::author_details_ui::{ @@ -22,6 +22,7 @@ mod tests { author_details_blocks.extend(BOOK_DETAILS_BLOCKS); author_details_blocks.extend(EDITION_DETAILS_BLOCKS); author_details_blocks.extend(DELETE_BOOK_BLOCKS); + author_details_blocks.extend(AUTHOR_OVERVIEW_BLOCKS); ActiveReadarrBlock::iter().for_each(|active_readarr_block| { if author_details_blocks.contains(&active_readarr_block) { @@ -149,6 +150,7 @@ mod tests { } mod snapshot_tests { + use pretty_assertions::assert_str_eq; use rstest::rstest; use crate::app::App; @@ -337,6 +339,46 @@ mod tests { insta::assert_snapshot!(output); } + #[test] + fn test_author_details_ui_renders_author_overview_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_scrolling_the_author_overview_does_not_scroll_the_author_details_background() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + let before = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + app + .data + .readarr_data + .author_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 3; + + let after = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + assert_str_eq!(before, after); + assert_contains!( + before, + "Overview: some interesting description of the author" + ); + } + #[test] fn test_author_details_ui_renders_delete_book_over_author_details() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/readarr_ui/library/author_overview_ui.rs b/src/ui/readarr_ui/library/author_overview_ui.rs new file mode 100644 index 00000000..bafb3c9a --- /dev/null +++ b/src/ui/readarr_ui/library/author_overview_ui.rs @@ -0,0 +1,87 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::Author; +use crate::models::servarr_data::readarr::readarr_data::AUTHOR_OVERVIEW_BLOCKS; +use crate::ui::styles::{primary_style, unmonitored_style}; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +#[cfg(test)] +#[path = "author_overview_ui_tests.rs"] +mod author_overview_ui_tests; + +pub(super) struct AuthorOverviewUi; + +impl DrawUi for AuthorOverviewUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + AUTHOR_OVERVIEW_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if app.data.readarr_data.author_overview_modal.is_some() { + let draw_author_overview_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + draw_author_overview(f, app, popup_area); + }; + + draw_popup(f, app, draw_author_overview_popup, Size::Large); + } + } +} + +fn draw_author_overview(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block_centered("Overview"); + + match app.data.readarr_data.author_overview_modal.as_ref() { + Some(author_overview_modal) if !app.is_loading => { + let style = if app.data.readarr_data.authors.is_empty() { + primary_style() + } else { + style_from_author(app.data.readarr_data.authors.current_selection()) + }; + let overview = &author_overview_modal.overview; + let text = Text::from( + overview + .items + .iter() + .map(|line| overview_line(line, style)) + .collect::>>(), + ); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((overview.offset, 0)); + + f.render_widget(paragraph, area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.author_overview_modal.is_none(), + block, + ), + area, + ), + } +} + +fn style_from_author(author: &Author) -> Style { + if !author.monitored { + return unmonitored_style(); + } + + primary_style() +} + +fn overview_line(line: &str, style: Style) -> Line<'static> { + Line::from(Span::styled(line.trim_end().to_owned(), style)) +} diff --git a/src/ui/readarr_ui/library/author_overview_ui_tests.rs b/src/ui/readarr_ui/library/author_overview_ui_tests.rs new file mode 100644 index 00000000..c87bd4e2 --- /dev/null +++ b/src/ui/readarr_ui/library/author_overview_ui_tests.rs @@ -0,0 +1,205 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::style::Modifier; + use ratatui::text::Line; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::readarr_models::Author; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::author_overview_ui::{ + AuthorOverviewUi, overview_line, style_from_author, + }; + use crate::ui::styles::{primary_style, unmonitored_style}; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_author_overview_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if AUTHOR_OVERVIEW_BLOCKS.contains(&active_readarr_block) { + assert!(AuthorOverviewUi::accepts(active_readarr_block.into())); + } else { + assert!(!AuthorOverviewUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_style_from_author_monitored() { + let author = Author { + monitored: true, + ..Author::default() + }; + + let style = style_from_author(&author); + + assert_eq!(style, primary_style()); + } + + #[test] + fn test_style_from_author_unmonitored() { + let author = Author { + monitored: false, + ..Author::default() + }; + + let style = style_from_author(&author); + + assert_eq!(style, unmonitored_style()); + } + + #[test] + fn test_overview_line_does_not_split_a_label_from_a_colon_in_the_prose() { + let line = overview_line( + "He was born in Madison, Wisconsin: a city he has never really left.", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["He was born in Madison, Wisconsin: a city he has never really left."] + ); + } + + #[test] + fn test_overview_line_renders_the_prose_without_bolding_it() { + let line = overview_line("Overview: a prose line", primary_style()); + + assert_eq!(line.spans.len(), 1); + assert!(!line.spans[0].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_overview_line_applies_the_given_style() { + let line = overview_line("an unmonitored author", unmonitored_style()); + + assert_eq!(line.spans[0].style.fg, unmonitored_style().fg); + } + + #[test] + fn test_overview_line_trims_a_trailing_carriage_return() { + let line = overview_line( + "His first novel took him seven years to finish.\r", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["His first novel took him seven years to finish."] + ); + } + + #[test] + fn test_overview_line_renders_a_carriage_return_only_line_as_blank() { + let line = overview_line("\r", primary_style()); + + assert_eq!(span_contents(&line), vec![""]); + } + + fn span_contents(line: &Line<'_>) -> Vec { + line + .spans + .iter() + .map(|span| span.content.to_string()) + .collect() + } + + mod snapshot_tests { + use crate::models::ScrollableText; + use crate::models::servarr_data::readarr::modals::AuthorOverviewModal; + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_author_overview_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_scrolled() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + app + .data + .readarr_data + .author_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_an_author_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + app.data.readarr_data.author_overview_modal = Some(AuthorOverviewModal { + overview: ScrollableText::with_string(String::new()), + }); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_nothing_when_the_author_overview_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + app.data.readarr_data.author_overview_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_with_zero_authors() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.authors = StatefulTable::default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "some interesting description of the author"); + } + } +} diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs index 1d0b4406..f491ca53 100644 --- a/src/ui/readarr_ui/library/library_ui_tests.rs +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -4,9 +4,9 @@ mod tests { use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ - ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, - DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, EDITION_DETAILS_BLOCKS, - LIBRARY_BLOCKS, + ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, + BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, + EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; @@ -18,6 +18,7 @@ mod tests { fn test_library_ui_accepts() { let mut blocks = LIBRARY_BLOCKS.to_vec(); blocks.extend(AUTHOR_DETAILS_BLOCKS); + blocks.extend(AUTHOR_OVERVIEW_BLOCKS); blocks.extend(BOOK_DETAILS_BLOCKS); blocks.extend(EDITION_DETAILS_BLOCKS); blocks.extend(ADD_AUTHOR_BLOCKS); diff --git a/src/ui/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index 2467a643..2f6a3655 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -30,6 +30,7 @@ use crate::{ mod add_author_ui; mod author_details_ui; +mod author_overview_ui; mod book_details_ui; mod delete_author_ui; mod delete_book_ui; diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap index bf512d36..42cd616f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap index 00dd5514..894486a6 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ─────────────╭─────────────────────────────────── Details ───────────────────────────────────╮───────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap index 7e6599c4..9c5a6818 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap index 941f5dcc..8a4b2a62 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap index 937cd23e..b511dc00 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap index da4145de..79d6d417 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap index 84092693..5b2ea803 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap index bfd24b55..d45da62e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap index 5915ff9f..19a84629 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap index f52c05a8..49b8043e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ ╭───────────────── Download Rejected Release ──────────────────╮ │ │╭ Author Details ──────────────────────│ Do you really want to download the rejected release: Test │───────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap index 6655add5..af676b20 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap index 2de5af4d..9613ce4f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap index 2d943a33..0a17a013 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap index b81bc8bb..fd17c74d 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap index cebdff92..68f27a1c 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap index e3a9fdbb..3ef32721 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap index 8cec0e85..0c096c2f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap index 4315396b..71d50e3a 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap index 13882a49..4aa3a45d 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap new file mode 100644 index 00000000..e9623818 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: sci╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ │ + │Rating: N/A│some interesting description of the author │ │ + │Path: /nfs/│ │ │ + │Quality Pro│He was born in Madison, Wisconsin: a city he has never really left. │ │ + │Metadata Pr│ │ │ + │Monitored: │His first novel took him seven years to finish. │ │ + │Overview: s│ │took him │ + │seven years│ │ │ + │ │ │ │ + │ │ │ │ + │╭ Author D│ │───────────╮│ + ││ Books │ H│ │ ││ + ││──────────│ │───────────││ + ││ Monitor│ │ ││ + ││=> 🏷 │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap index 937cd23e..b511dc00 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap index b681ef93..36b06222 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap @@ -14,8 +14,8 @@ expression: output │Qual│ │ │ │Meta│ │ │ │Moni│ │ │ - │Over│ │ │ - │ │ │ │ + │Over│ │im │ + │seve│ │ │ │ │ │ │ │ │ │ │ │╭ A│ │─────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap index 6a98fe7d..ec5d4151 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────╭────────────────────── Delete Book ──────────────────────╮───────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap index 82d57a62..4c3bf601 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap @@ -17,7 +17,7 @@ expression: output │Books: 3 │ │Files: 2/3 │ │Size on Disk: 3.30 GB │ - │Overview: some interesting description of the author │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap index 8cec0e85..0c096c2f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap index 5ffdc770..e81cb685 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap index 5ffdc770..e81cb685 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap index 420df740..90609ffe 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ─────────────╭─────────────────────────────────── Details ───────────────────────────────────╮───────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap index 7f1eb28e..85109ff3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap index 7f1eb28e..85109ff3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap index 7f1eb28e..85109ff3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap index 7f1eb28e..85109ff3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders.snap new file mode 100644 index 00000000..75be124b --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │some interesting description of the author │ + │ │ + │He was born in Madison, Wisconsin: a city he has never really left. │ + │ │ + │His first novel took him seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_an_author_with_no_overview.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_an_author_with_no_overview.snap new file mode 100644 index 00000000..d4a877ae --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_an_author_with_no_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_loading.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_loading.snap new file mode 100644 index 00000000..a8a3c9e0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_nothing_when_the_author_overview_modal_is_undefined.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_nothing_when_the_author_overview_modal_is_undefined.snap new file mode 100644 index 00000000..b080be01 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_nothing_when_the_author_overview_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_scrolled.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_scrolled.snap new file mode 100644 index 00000000..4eb9e128 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_scrolled.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │He was born in Madison, Wisconsin: a city he has never really left. │ + │ │ + │His first novel took him seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap index 63e608f7..53654aca 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: St│ │ │ │Metadata Profile: S│ │ │ │Monitored: Yes │ │ │ - │Overview: some inte│ │ │ - │ │ │ │ + │Overview: some inte│ │st novel took him │ + │seven years to fini│ │ │ │ │ │ │ │ │ ╭───╮ │ │ │╭ Author Details │ Monitored: │ ✔ │ │────────────────────╮│ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap index cc7cd2a4..5a645d25 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -14,8 +14,8 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Overview: some interesting description of the author │ - │ │ + │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │seven years to finish. │ │ │ │ │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ From ca04498717d33f53d7186222a59bcf98829b7992 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 21:05:50 -0600 Subject: [PATCH 105/170] feat(lidarr): add a scrollable overview popup to artist details An artist's overview renders as one line of a description box that does not scroll, so a long overview overflows with no way to read the rest. Pressing v on the artist details Albums tab now opens a popup holding the full overview. The popup owns its own ScrollableText, built fresh from the artist's overview when it opens and torn down on Esc so a reopened popup starts at the top. Scrolling mutates only that owned state. The background paragraph renders the Artist record's overview, which carries no scroll offset, so it cannot scroll along with the popup. Cite the existing view keybinding from the artist details context clues and give the popup its own clue set so it does not inherit the hints of the view underneath. Enrich the shared artist fixture to a multi-paragraph overview, and the blocklist response fixtures that embed the same artist, so a scrolled popup is distinguishable from an unscrolled one. --- src/app/lidarr/lidarr_context_clues.rs | 18 +- src/app/lidarr/lidarr_context_clues_tests.rs | 37 +- .../library/artist_details_handler.rs | 38 +- .../library/artist_details_handler_tests.rs | 175 +++++++- .../library/artist_overview_handler.rs | 109 +++++ .../library/artist_overview_handler_tests.rs | 393 ++++++++++++++++++ .../library/library_handler_tests.rs | 7 +- src/handlers/lidarr_handlers/library/mod.rs | 1 + src/models/servarr_data/lidarr/lidarr_data.rs | 14 +- .../servarr_data/lidarr/lidarr_data_tests.rs | 24 +- src/models/servarr_data/lidarr/modals.rs | 6 + .../servarr_data/lidarr/modals_tests.rs | 9 +- .../lidarr_blocklist_network_tests.rs | 8 +- .../lidarr_network_test_utils.rs | 9 +- src/ui/lidarr_ui/library/artist_details_ui.rs | 6 + .../library/artist_details_ui_tests.rs | 46 +- .../lidarr_ui/library/artist_overview_ui.rs | 87 ++++ .../library/artist_overview_ui_tests.rs | 205 +++++++++ src/ui/lidarr_ui/library/library_ui_tests.rs | 7 +- src/ui/lidarr_ui/library/mod.rs | 1 + ...tests__artist_details_ArtistDetails_0.snap | 4 +- ...artist_details_ArtistHistoryDetails_1.snap | 8 +- ...ist_details_ArtistHistorySortPrompt_1.snap | 4 +- ...tests__artist_details_ArtistHistory_1.snap | 4 +- ...ils_AutomaticallySearchArtistPrompt_0.snap | 8 +- ...ils_AutomaticallySearchArtistPrompt_1.snap | 8 +- ...ils_AutomaticallySearchArtistPrompt_2.snap | 8 +- ...st_details_FilterArtistHistoryError_1.snap | 4 +- ..._artist_details_FilterArtistHistory_1.snap | 4 +- ...ils_ManualArtistSearchConfirmPrompt_2.snap | 10 +- ...etails_ManualArtistSearchSortPrompt_2.snap | 4 +- ...__artist_details_ManualArtistSearch_2.snap | 4 +- ...s__artist_details_SearchAlbumsError_0.snap | 4 +- ..._tests__artist_details_SearchAlbums_0.snap | 4 +- ...st_details_SearchArtistHistoryError_1.snap | 4 +- ..._artist_details_SearchArtistHistory_1.snap | 4 +- ...t_details_UpdateAndScanArtistPrompt_0.snap | 8 +- ...t_details_UpdateAndScanArtistPrompt_1.snap | 8 +- ...t_details_UpdateAndScanArtistPrompt_2.snap | 8 +- ...s_artist_overview_over_artist_details.snap | 52 +++ ...tic_search_prompt_over_artist_details.snap | 8 +- ...lete_album_prompt_over_artist_details.snap | 8 +- ...e_and_scan_prompt_over_artist_details.snap | 8 +- ...s__empty_artist_details_ArtistDetails.snap | 4 +- ...s__empty_artist_details_ArtistHistory.snap | 4 +- ...y_artist_details_ArtistHistoryDetails.snap | 8 +- ...pty_artist_details_ManualArtistSearch.snap | 4 +- ..._loading_artist_details_ArtistDetails.snap | 4 +- ..._loading_artist_details_ArtistHistory.snap | 4 +- ...ing_artist_details_ManualArtistSearch.snap | 4 +- ...hot_tests__artist_overview_ui_renders.snap | 48 +++ ...ui_renders_an_artist_with_no_overview.snap | 48 +++ ...s__artist_overview_ui_renders_loading.snap | 48 +++ ...he_artist_overview_modal_is_undefined.snap | 5 + ...__artist_overview_ui_renders_scrolled.snap | 48 +++ ...dit_artist_renders_over_ArtistDetails.snap | 12 +- ...dit_artist_renders_over_ArtistHistory.snap | 12 +- ...rtist_renders_over_ManualArtistSearch.snap | 12 +- ...i_renders_artist_details_over_library.snap | 4 +- 59 files changed, 1521 insertions(+), 134 deletions(-) create mode 100644 src/handlers/lidarr_handlers/library/artist_overview_handler.rs create mode 100644 src/handlers/lidarr_handlers/library/artist_overview_handler_tests.rs create mode 100644 src/ui/lidarr_ui/library/artist_overview_ui.rs create mode 100644 src/ui/lidarr_ui/library/artist_overview_ui_tests.rs create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_an_artist_with_no_overview.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_loading.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_nothing_when_the_artist_overview_modal_is_undefined.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_scrolled.snap diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index 61da0c35..27737fe5 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -7,8 +7,8 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ARTIST_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, - ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, - EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, TRACK_DETAILS_BLOCKS, + ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, + EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, TRACK_DETAILS_BLOCKS, }; #[cfg(test)] @@ -39,7 +39,7 @@ pub static ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.esc, "edit search"), ]; -pub static ARTIST_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ +pub static ARTIST_DETAILS_CONTEXT_CLUES: [ContextClue; 9] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, @@ -56,6 +56,15 @@ pub static ARTIST_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ DEFAULT_KEYBINDINGS.auto_search, DEFAULT_KEYBINDINGS.auto_search.desc, ), + (DEFAULT_KEYBINDINGS.view, "view overview"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static ARTIST_OVERVIEW_CONTEXT_CLUES: [ContextClue; 2] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; @@ -172,6 +181,9 @@ impl ContextClueProvider for LidarrContextClueProvider { .lidarr_data .artist_info_tabs .get_active_route_contextual_help(), + _ if ARTIST_OVERVIEW_BLOCKS.contains(&active_lidarr_block) => { + Some(&ARTIST_OVERVIEW_CONTEXT_CLUES) + } _ if ALBUM_DETAILS_BLOCKS.contains(&active_lidarr_block) => app .data .lidarr_data diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 57207974..b5f178eb 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -9,8 +9,9 @@ mod tests { use crate::app::lidarr::lidarr_context_clues::{ ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ALBUM_DETAILS_CONTEXT_CLUES, ALBUM_HISTORY_CONTEXT_CLUES, ARTIST_DETAILS_CONTEXT_CLUES, ARTIST_HISTORY_CONTEXT_CLUES, - ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, MANUAL_ALBUM_SEARCH_CONTEXT_CLUES, - MANUAL_ARTIST_SEARCH_CONTEXT_CLUES, TRACK_DETAILS_CONTEXT_CLUES, TRACK_HISTORY_CONTEXT_CLUES, + ARTIST_OVERVIEW_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, + MANUAL_ALBUM_SEARCH_CONTEXT_CLUES, MANUAL_ARTIST_SEARCH_CONTEXT_CLUES, + TRACK_DETAILS_CONTEXT_CLUES, TRACK_HISTORY_CONTEXT_CLUES, }; use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ROOT_FOLDER_BLOCKS, ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, @@ -114,6 +115,10 @@ mod tests { DEFAULT_KEYBINDINGS.auto_search.desc, ) ); + assert_some_eq_x!( + artist_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.view, "view overview") + ); assert_some_eq_x!( artist_details_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) @@ -121,6 +126,24 @@ mod tests { assert_none!(artist_details_context_clues_iter.next()); } + #[test] + fn test_artist_overview_context_clues() { + let mut artist_overview_context_clues_iter = ARTIST_OVERVIEW_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + artist_overview_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + artist_overview_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(artist_overview_context_clues_iter.next()); + } + #[test] fn test_add_artist_search_results_context_clues() { let mut add_artist_search_results_context_clues_iter = @@ -471,6 +494,16 @@ mod tests { assert_some_eq_x!(context_clues, expected_context_clues); } + #[test] + fn test_lidarr_context_clue_provider_artist_overview_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTIST_OVERVIEW_CONTEXT_CLUES); + } + #[test] fn test_lidarr_context_clue_provider_artists_block() { let mut app = App::test_default(); diff --git a/src/handlers/lidarr_handlers/library/artist_details_handler.rs b/src/handlers/lidarr_handlers/library/artist_details_handler.rs index 0b5908eb..ed39ed60 100644 --- a/src/handlers/lidarr_handlers/library/artist_details_handler.rs +++ b/src/handlers/lidarr_handlers/library/artist_details_handler.rs @@ -1,3 +1,4 @@ +use super::artist_overview_handler::ArtistOverviewHandler; use crate::app::App; use crate::event::Key; use crate::handlers::lidarr_handlers::history::history_sorting_options; @@ -9,9 +10,10 @@ use crate::models::servarr_data::lidarr::lidarr_data::{ ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_SELECTION_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, }; +use crate::models::servarr_data::lidarr::modals::ArtistOverviewModal; use crate::models::servarr_models::ReleaseDownloadBody; use crate::models::stateful_table::SortOption; -use crate::models::{BlockSelectionState, Route}; +use crate::models::{BlockSelectionState, Route, ScrollableText}; use crate::network::lidarr_network::LidarrEvent; use serde_json::Number; @@ -34,10 +36,36 @@ impl ArtistDetailsHandler<'_, '_> { fn extract_album_id(&self) -> i64 { self.app.data.lidarr_data.albums.current_selection().id } + + fn build_artist_overview_modal(&mut self) { + let overview = self + .app + .data + .lidarr_data + .artists + .current_selection() + .overview + .clone() + .unwrap_or_default(); + + self.app.data.lidarr_data.artist_overview_modal = Some(ArtistOverviewModal { + overview: ScrollableText::with_string(overview), + }); + } } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler<'a, 'b> { fn handle(&mut self) { + if ArtistOverviewHandler::accepts(self.active_lidarr_block) { + return ArtistOverviewHandler::new( + self.key, + self.app, + self.active_lidarr_block, + self._context, + ) + .handle(); + } + let albums_table_handling_config = TableHandlingConfig::new(ActiveLidarrBlock::ArtistDetails.into()) .searching_block(ActiveLidarrBlock::SearchAlbums.into()) @@ -78,7 +106,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler } fn accepts(active_block: ActiveLidarrBlock) -> bool { - ARTIST_DETAILS_BLOCKS.contains(&active_block) + ArtistOverviewHandler::accepts(active_block) || ARTIST_DETAILS_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { @@ -292,6 +320,12 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler self.app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); } + _ if matches_key!(view, key) => { + self.build_artist_overview_modal(); + self + .app + .push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + } _ if matches_key!(toggle_monitoring, key) && !self.app.data.lidarr_data.albums.is_empty() => { diff --git a/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs index 1a0aa5e5..34eb1916 100644 --- a/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs @@ -15,9 +15,10 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, + ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, }; use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::test_handler_delegation; mod test_handle_delete { use super::*; @@ -450,6 +451,7 @@ mod tests { use crate::assert_navigation_pushed; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler; + use crate::handlers::lidarr_handlers::library::artist_overview_handler::ArtistOverviewHandler; use crate::models::lidarr_models::Artist; use crate::models::servarr_data::lidarr::lidarr_data::{ ActiveLidarrBlock, EDIT_ARTIST_SELECTION_BLOCKS, @@ -458,7 +460,7 @@ mod tests { use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::torrent_release; use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; #[rstest] @@ -524,6 +526,161 @@ mod tests { assert_modal_absent!(app.data.lidarr_data.edit_artist_modal); } + #[test] + fn test_artist_details_view_key_opens_the_artist_overview() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into()); + assert_modal_present!(app.data.lidarr_data.artist_overview_modal); + } + + #[test] + fn test_artist_details_view_key_builds_the_overview_from_the_selected_artist() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + let overview = &app + .data + .lidarr_data + .artist_overview_modal + .as_ref() + .unwrap() + .overview; + assert_eq!(overview.offset, 0); + assert_eq!( + overview.get_text(), + app + .data + .lidarr_data + .artists + .current_selection() + .overview + .clone() + .unwrap() + ); + } + + #[test] + fn test_artist_details_view_key_for_an_artist_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + let mut artist = app.data.lidarr_data.artists.current_selection().clone(); + artist.overview = None; + app.data.lidarr_data.artists.set_items(vec![artist]); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into()); + assert_str_eq!( + app + .data + .lidarr_data + .artist_overview_modal + .as_ref() + .unwrap() + .overview + .get_text(), + "" + ); + } + + #[test] + fn test_artist_details_view_key_reopens_the_artist_overview_at_the_top() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into()); + assert_eq!( + app + .data + .lidarr_data + .artist_overview_modal + .as_ref() + .unwrap() + .overview + .offset, + 0 + ); + } + + #[test] + fn test_artist_details_view_key_no_op_when_not_ready() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::ArtistDetails.into() + ); + assert_modal_absent!(app.data.lidarr_data.artist_overview_modal); + } + #[test] fn test_artist_details_toggle_monitoring_key() { let mut app = App::test_default_fully_populated(); @@ -813,8 +970,11 @@ mod tests { #[test] fn test_artist_details_handler_accepts() { + let mut artist_details_blocks = ARTIST_DETAILS_BLOCKS.to_vec(); + artist_details_blocks.extend(ARTIST_OVERVIEW_BLOCKS); + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { - if ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) { + if artist_details_blocks.contains(&active_lidarr_block) { assert!(ArtistDetailsHandler::accepts(active_lidarr_block)); } else { assert!(!ArtistDetailsHandler::accepts(active_lidarr_block)); @@ -822,6 +982,15 @@ mod tests { }); } + #[test] + fn test_delegates_artist_overview_blocks_to_artist_overview_handler() { + test_handler_delegation!( + ArtistDetailsHandler, + ActiveLidarrBlock::ArtistDetails, + ActiveLidarrBlock::ArtistOverview + ); + } + #[test] fn test_extract_artist_id() { let mut app = App::test_default_fully_populated(); diff --git a/src/handlers/lidarr_handlers/library/artist_overview_handler.rs b/src/handlers/lidarr_handlers/library/artist_overview_handler.rs new file mode 100644 index 00000000..2da3f8ef --- /dev/null +++ b/src/handlers/lidarr_handlers/library/artist_overview_handler.rs @@ -0,0 +1,109 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::matches_key; +use crate::models::servarr_data::lidarr::lidarr_data::{ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock}; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "artist_overview_handler_tests.rs"] +mod artist_overview_handler_tests; + +pub(super) struct ArtistOverviewHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + _context: Option, +} + +impl ArtistOverviewHandler<'_, '_> { + fn overview(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .lidarr_data + .artist_overview_modal + .as_mut() + .expect("Artist overview modal is undefined") + .overview + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistOverviewHandler<'a, 'b> { + fn accepts(active_block: ActiveLidarrBlock) -> bool { + ARTIST_OVERVIEW_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + _context: Option, + ) -> ArtistOverviewHandler<'a, 'b> { + ArtistOverviewHandler { + key, + app, + active_lidarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + self.app.data.lidarr_data.artist_overview_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.overview().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.overview().scroll_down(); + } + + fn handle_home(&mut self) { + self.overview().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.overview().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) { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.artist_overview_modal = None; + } + + fn handle_char_key_event(&mut self) { + if matches_key!(refresh, self.key) { + self + .app + .pop_and_push_navigation_stack(self.active_lidarr_block.into()); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/lidarr_handlers/library/artist_overview_handler_tests.rs b/src/handlers/lidarr_handlers/library/artist_overview_handler_tests.rs new file mode 100644 index 00000000..1f8936fe --- /dev/null +++ b/src/handlers/lidarr_handlers/library/artist_overview_handler_tests.rs @@ -0,0 +1,393 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::library::artist_overview_handler::ArtistOverviewHandler; + use crate::models::ScrollableText; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, + }; + use crate::models::servarr_data::lidarr::modals::ArtistOverviewModal; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_artist_overview_scroll_up() { + let mut app = app_with_artist_overview(); + set_offset(&mut app, 2); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_artist_overview_scroll_up_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + set_offset(&mut app, 2); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 2); + } + + #[test] + fn test_artist_overview_scroll_down() { + let mut app = app_with_artist_overview(); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_artist_overview_scroll_down_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_artist_overview_home() { + let mut app = app_with_artist_overview(); + set_offset(&mut app, 3); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + + #[test] + fn test_artist_overview_end() { + let mut app = app_with_artist_overview(); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + } + + #[test] + fn test_artist_overview_home_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + set_offset(&mut app, 3); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 3); + } + + #[test] + fn test_artist_overview_end_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_navigation_popped}; + + #[test] + fn test_artist_overview_esc_tears_down_the_artist_overview_modal() { + let mut app = app_with_artist_overview(); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::ArtistDetails.into()); + assert_modal_absent!(app.data.lidarr_data.artist_overview_modal); + } + + #[test] + fn test_artist_overview_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::ArtistDetails.into()); + assert_modal_absent!(app.data.lidarr_data.artist_overview_modal); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_artist_overview_refresh_key() { + let mut app = app_with_artist_overview(); + app.is_routing = false; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into()); + assert!(app.is_routing); + } + + #[test] + fn test_artist_overview_refresh_key_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + app.is_routing = false; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert!(!app.is_routing); + } + } + + #[test] + fn test_scrolling_the_overview_does_not_mutate_the_artist_record() { + let mut app = app_with_artist_overview(); + let overview_before = app + .data + .lidarr_data + .artists + .current_selection() + .overview + .clone(); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + assert_eq!( + app.data.lidarr_data.artists.current_selection().overview, + overview_before + ); + } + + #[test] + fn test_artist_overview_handler_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if ARTIST_OVERVIEW_BLOCKS.contains(&active_lidarr_block) { + assert!( + ArtistOverviewHandler::accepts(active_lidarr_block), + "{active_lidarr_block} is not accepted by the ArtistOverviewHandler" + ); + } else { + assert!( + !ArtistOverviewHandler::accepts(active_lidarr_block), + "{active_lidarr_block} is wrongly accepted by the ArtistOverviewHandler" + ); + } + }); + } + + #[test] + fn test_artist_overview_handler_dispatches_no_network_events() { + let mut app = app_with_artist_overview(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + ArtistOverviewHandler::new(key, &mut app, ActiveLidarrBlock::ArtistOverview, None).handle(); + + assert_none!(app.data.lidarr_data.prompt_confirm_action); + assert!(!app.data.lidarr_data.prompt_confirm); + } + } + + #[test] + fn test_artist_overview_handler_is_not_ready_when_loading() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + + let handler = ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_artist_overview_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = app_with_artist_overview(); + app.data.lidarr_data.artist_overview_modal = None; + + let handler = ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_artist_overview_handler_is_ready() { + let mut app = app_with_artist_overview(); + + let handler = ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_artist_overview_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_artist_overview(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_artist_overview() -> App<'static> { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = Some(ArtistOverviewModal { + overview: ScrollableText::with_string( + "some interesting description of the artist\r\n\ + \r\n\ + She was born in Madison, Wisconsin: a city she has never really left.\r\n\ + \r\n\ + Her first album took her seven years to finish.\r\n" + .to_owned(), + ), + }); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + app + } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .lidarr_data + .artist_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .lidarr_data + .artist_overview_modal + .as_mut() + .unwrap() + .overview + .offset = offset; + } +} diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index b28cb156..85ca1d34 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -13,9 +13,9 @@ mod tests { use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Album, Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ADD_ARTIST_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, - DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, - LIBRARY_BLOCKS, TRACK_DETAILS_BLOCKS, + ADD_ARTIST_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, + ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, + EDIT_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS, TRACK_DETAILS_BLOCKS, }; use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::network::lidarr_network::LidarrEvent; @@ -29,6 +29,7 @@ mod tests { let mut library_handler_blocks = Vec::new(); library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(ARTIST_DETAILS_BLOCKS); + library_handler_blocks.extend(ARTIST_OVERVIEW_BLOCKS); library_handler_blocks.extend(DELETE_ARTIST_BLOCKS); library_handler_blocks.extend(DELETE_ALBUM_BLOCKS); library_handler_blocks.extend(EDIT_ARTIST_BLOCKS); diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index 82db4b2d..5334dd27 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -21,6 +21,7 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; mod add_artist_handler; mod album_details_handler; mod artist_details_handler; +mod artist_overview_handler; mod delete_album_handler; mod delete_artist_handler; mod edit_artist_handler; diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 497c828e..0314f1ea 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,6 +1,8 @@ use serde_json::Number; -use super::modals::{AddArtistModal, AddRootFolderModal, AlbumDetailsModal, EditArtistModal}; +use super::modals::{ + AddArtistModal, AddRootFolderModal, AlbumDetailsModal, ArtistOverviewModal, EditArtistModal, +}; use crate::app::context_clues::{ BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, @@ -64,6 +66,7 @@ pub struct LidarrData<'a> { pub album_details_modal: Option, pub artist_history: StatefulTable, pub artist_info_tabs: TabState, + pub artist_overview_modal: Option, pub artists: StatefulTable, pub blocklist: StatefulTable, pub delete_files: bool, @@ -150,6 +153,7 @@ impl<'a> Default for LidarrData<'a> { albums: StatefulTable::default(), album_details_modal: None, artist_history: StatefulTable::default(), + artist_overview_modal: None, artists: StatefulTable::default(), blocklist: StatefulTable::default(), delete_files: false, @@ -354,8 +358,13 @@ impl LidarrData<'_> { let mut indexer_test_all_results = StatefulTable::default(); indexer_test_all_results.set_items(vec![indexer_test_result()]); + let artist_overview_modal = ArtistOverviewModal { + overview: ScrollableText::with_string(artist().overview.unwrap_or_default()), + }; + let mut lidarr_data = LidarrData { album_details_modal: Some(album_details_modal), + artist_overview_modal: Some(artist_overview_modal), delete_files: true, disk_space_vec: vec![diskspace()], quality_profile_map: quality_profile_map(), @@ -428,6 +437,7 @@ pub enum ActiveLidarrBlock { ArtistHistory, ArtistHistoryDetails, ArtistHistorySortPrompt, + ArtistOverview, ArtistsSortPrompt, AddArtistAlreadyInLibrary, AddArtistConfirmPrompt, @@ -579,6 +589,8 @@ pub static ARTIST_DETAILS_BLOCKS: [ActiveLidarrBlock; 15] = [ ActiveLidarrBlock::UpdateAndScanArtistPrompt, ]; +pub static ARTIST_OVERVIEW_BLOCKS: [ActiveLidarrBlock; 1] = [ActiveLidarrBlock::ArtistOverview]; + pub static ALBUM_DETAILS_BLOCKS: [ActiveLidarrBlock; 15] = [ ActiveLidarrBlock::AlbumDetails, ActiveLidarrBlock::AlbumHistory, diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index 308024b3..2bdb8807 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -11,12 +11,12 @@ mod tests { use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, - ARTIST_DETAILS_BLOCKS, BLOCKLIST_BLOCKS, DELETE_ALBUM_BLOCKS, DELETE_ALBUM_SELECTION_BLOCKS, - DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_ARTIST_BLOCKS, - EDIT_ARTIST_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_INDEXER_NZB_SELECTION_BLOCKS, - EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXER_SETTINGS_BLOCKS, - INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, ROOT_FOLDERS_BLOCKS, SYSTEM_DETAILS_BLOCKS, - TRACK_DETAILS_BLOCKS, + ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, BLOCKLIST_BLOCKS, DELETE_ALBUM_BLOCKS, + DELETE_ALBUM_SELECTION_BLOCKS, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, + DOWNLOADS_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, HISTORY_BLOCKS, + INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, + ROOT_FOLDERS_BLOCKS, SYSTEM_DETAILS_BLOCKS, TRACK_DETAILS_BLOCKS, }; use crate::models::{ BlockSelectionState, Route, @@ -149,6 +149,7 @@ mod tests { assert_none!(lidarr_data.album_details_modal); assert_is_empty!(lidarr_data.artists); assert_is_empty!(lidarr_data.artist_history); + assert_none!(lidarr_data.artist_overview_modal); assert_is_empty!(lidarr_data.blocklist); assert!(!lidarr_data.delete_files); assert_is_empty!(lidarr_data.disk_space_vec); @@ -318,6 +319,17 @@ mod tests { assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::UpdateAndScanArtistPrompt)); } + #[test] + fn test_artist_details_blocks_excludes_artist_overview() { + assert!(!ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::ArtistOverview)); + } + + #[test] + fn test_artist_overview_blocks_contents() { + assert_eq!(ARTIST_OVERVIEW_BLOCKS.len(), 1); + assert!(ARTIST_OVERVIEW_BLOCKS.contains(&ActiveLidarrBlock::ArtistOverview)); + } + #[test] fn test_album_details_blocks_contents() { assert_eq!(ALBUM_DETAILS_BLOCKS.len(), 15); diff --git a/src/models/servarr_data/lidarr/modals.rs b/src/models/servarr_data/lidarr/modals.rs index c969c47e..48732dd6 100644 --- a/src/models/servarr_data/lidarr/modals.rs +++ b/src/models/servarr_data/lidarr/modals.rs @@ -294,3 +294,9 @@ impl Default for TrackDetailsModal { } } } + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct ArtistOverviewModal { + pub overview: ScrollableText, +} diff --git a/src/models/servarr_data/lidarr/modals_tests.rs b/src/models/servarr_data/lidarr/modals_tests.rs index 44193265..7f79ac35 100644 --- a/src/models/servarr_data/lidarr/modals_tests.rs +++ b/src/models/servarr_data/lidarr/modals_tests.rs @@ -7,7 +7,7 @@ mod tests { use crate::models::lidarr_models::{Artist, MonitorType, NewItemMonitorType}; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}; use crate::models::servarr_data::lidarr::modals::{ - AddArtistModal, AlbumDetailsModal, EditArtistModal, TrackDetailsModal, + AddArtistModal, AlbumDetailsModal, ArtistOverviewModal, EditArtistModal, TrackDetailsModal, }; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_models::{Indexer, IndexerField, RootFolder}; @@ -307,4 +307,11 @@ mod tests { ); assert_eq!(track_details_modal.track_details_tabs.tabs[1].config, None); } + + #[test] + fn test_artist_overview_modal_default() { + let artist_overview_modal = ArtistOverviewModal::default(); + + assert_is_empty!(artist_overview_modal.overview); + } } diff --git a/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs index 08540c62..23aab6de 100644 --- a/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs +++ b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs @@ -97,7 +97,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist", + "overview": "some interesting description of the artist\r\n\r\nShe was born in Madison, Wisconsin: a city she has never really left.\r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -136,7 +136,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist", + "overview": "some interesting description of the artist\r\n\r\nShe was born in Madison, Wisconsin: a city she has never really left.\r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -248,7 +248,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist", + "overview": "some interesting description of the artist\r\n\r\nShe was born in Madison, Wisconsin: a city she has never really left.\r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -286,7 +286,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist", + "overview": "some interesting description of the artist\r\n\r\nShe was born in Madison, Wisconsin: a city she has never really left.\r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index 716a4df1..bd956a26 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -110,7 +110,14 @@ pub mod test_utils { artist_name: "Alex".into(), foreign_artist_id: "test-foreign-id".to_owned(), status: ArtistStatus::Continuing, - overview: Some("some interesting description of the artist".to_owned()), + overview: Some( + "some interesting description of the artist\r\n\ + \r\n\ + She was born in Madison, Wisconsin: a city she has never really left.\r\n\ + \r\n\ + Her first album took her seven years to finish.\r\n" + .to_owned(), + ), artist_type: Some("Person".to_owned()), disambiguation: Some("American pianist".to_owned()), members: Some(vec![member()]), diff --git a/src/ui/lidarr_ui/library/artist_details_ui.rs b/src/ui/lidarr_ui/library/artist_details_ui.rs index 3cabf7b6..91e7d1ae 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui.rs @@ -12,6 +12,7 @@ use crate::models::Route; use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock}; use crate::ui::lidarr_ui::library::album_details_ui::AlbumDetailsUi; +use crate::ui::lidarr_ui::library::artist_overview_ui::ArtistOverviewUi; use crate::ui::lidarr_ui::library::delete_album_ui::DeleteAlbumUi; use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; @@ -43,6 +44,7 @@ impl DrawUi for ArtistDetailsUi { }; AlbumDetailsUi::accepts(route) || DeleteAlbumUi::accepts(route) + || ArtistOverviewUi::accepts(route) || ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) } @@ -122,6 +124,10 @@ impl DrawUi for ArtistDetailsUi { if AlbumDetailsUi::accepts(route) { AlbumDetailsUi::draw(f, app, area); } + + if ArtistOverviewUi::accepts(route) { + ArtistOverviewUi::draw(f, app, area); + } } } } diff --git a/src/ui/lidarr_ui/library/artist_details_ui_tests.rs b/src/ui/lidarr_ui/library/artist_details_ui_tests.rs index 0ce22c5a..bd9a00dd 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui_tests.rs @@ -3,8 +3,8 @@ mod tests { use strum::IntoEnumIterator; use crate::models::servarr_data::lidarr::lidarr_data::{ - ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, - TRACK_DETAILS_BLOCKS, + ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, + DELETE_ALBUM_BLOCKS, TRACK_DETAILS_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::artist_details_ui::ArtistDetailsUi; @@ -15,6 +15,7 @@ mod tests { blocks.extend(DELETE_ALBUM_BLOCKS); blocks.extend(ALBUM_DETAILS_BLOCKS); blocks.extend(TRACK_DETAILS_BLOCKS); + blocks.extend(ARTIST_OVERVIEW_BLOCKS); ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { if blocks.contains(&active_lidarr_block) { @@ -26,6 +27,7 @@ mod tests { } mod snapshot_tests { + use pretty_assertions::assert_str_eq; use rstest::rstest; use crate::app::App; @@ -138,6 +140,46 @@ mod tests { insta::assert_snapshot!(output); } + #[test] + fn test_artist_details_ui_renders_artist_overview_over_artist_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_scrolling_the_artist_overview_does_not_scroll_the_artist_details_background() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + let before = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistDetailsUi::draw(f, app, f.area()); + }); + + app + .data + .lidarr_data + .artist_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 3; + + let after = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistDetailsUi::draw(f, app, f.area()); + }); + assert_str_eq!(before, after); + assert_contains!( + before, + "Overview: some interesting description of the artist" + ); + } + #[test] fn test_artist_details_ui_renders_update_and_scan_prompt_over_artist_details() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/lidarr_ui/library/artist_overview_ui.rs b/src/ui/lidarr_ui/library/artist_overview_ui.rs new file mode 100644 index 00000000..dbe96f87 --- /dev/null +++ b/src/ui/lidarr_ui/library/artist_overview_ui.rs @@ -0,0 +1,87 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::lidarr_models::Artist; +use crate::models::servarr_data::lidarr::lidarr_data::ARTIST_OVERVIEW_BLOCKS; +use crate::ui::styles::{primary_style, unmonitored_style}; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +#[cfg(test)] +#[path = "artist_overview_ui_tests.rs"] +mod artist_overview_ui_tests; + +pub(super) struct ArtistOverviewUi; + +impl DrawUi for ArtistOverviewUi { + fn accepts(route: Route) -> bool { + let Route::Lidarr(active_lidarr_block, _) = route else { + return false; + }; + ARTIST_OVERVIEW_BLOCKS.contains(&active_lidarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if app.data.lidarr_data.artist_overview_modal.is_some() { + let draw_artist_overview_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + draw_artist_overview(f, app, popup_area); + }; + + draw_popup(f, app, draw_artist_overview_popup, Size::Large); + } + } +} + +fn draw_artist_overview(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block_centered("Overview"); + + match app.data.lidarr_data.artist_overview_modal.as_ref() { + Some(artist_overview_modal) if !app.is_loading => { + let style = if app.data.lidarr_data.artists.is_empty() { + primary_style() + } else { + style_from_artist(app.data.lidarr_data.artists.current_selection()) + }; + let overview = &artist_overview_modal.overview; + let text = Text::from( + overview + .items + .iter() + .map(|line| overview_line(line, style)) + .collect::>>(), + ); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((overview.offset, 0)); + + f.render_widget(paragraph, area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.lidarr_data.artist_overview_modal.is_none(), + block, + ), + area, + ), + } +} + +fn style_from_artist(artist: &Artist) -> Style { + if !artist.monitored { + return unmonitored_style(); + } + + primary_style() +} + +fn overview_line(line: &str, style: Style) -> Line<'static> { + Line::from(Span::styled(line.trim_end().to_owned(), style)) +} diff --git a/src/ui/lidarr_ui/library/artist_overview_ui_tests.rs b/src/ui/lidarr_ui/library/artist_overview_ui_tests.rs new file mode 100644 index 00000000..bd30c691 --- /dev/null +++ b/src/ui/lidarr_ui/library/artist_overview_ui_tests.rs @@ -0,0 +1,205 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::style::Modifier; + use ratatui::text::Line; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::lidarr_models::Artist; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, + }; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::library::artist_overview_ui::{ + ArtistOverviewUi, overview_line, style_from_artist, + }; + use crate::ui::styles::{primary_style, unmonitored_style}; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_artist_overview_ui_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if ARTIST_OVERVIEW_BLOCKS.contains(&active_lidarr_block) { + assert!(ArtistOverviewUi::accepts(active_lidarr_block.into())); + } else { + assert!(!ArtistOverviewUi::accepts(active_lidarr_block.into())); + } + }); + } + + #[test] + fn test_style_from_artist_monitored() { + let artist = Artist { + monitored: true, + ..Artist::default() + }; + + let style = style_from_artist(&artist); + + assert_eq!(style, primary_style()); + } + + #[test] + fn test_style_from_artist_unmonitored() { + let artist = Artist { + monitored: false, + ..Artist::default() + }; + + let style = style_from_artist(&artist); + + assert_eq!(style, unmonitored_style()); + } + + #[test] + fn test_overview_line_does_not_split_a_label_from_a_colon_in_the_prose() { + let line = overview_line( + "She was born in Madison, Wisconsin: a city she has never really left.", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["She was born in Madison, Wisconsin: a city she has never really left."] + ); + } + + #[test] + fn test_overview_line_renders_the_prose_without_bolding_it() { + let line = overview_line("Overview: a prose line", primary_style()); + + assert_eq!(line.spans.len(), 1); + assert!(!line.spans[0].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_overview_line_applies_the_given_style() { + let line = overview_line("an unmonitored artist", unmonitored_style()); + + assert_eq!(line.spans[0].style.fg, unmonitored_style().fg); + } + + #[test] + fn test_overview_line_trims_a_trailing_carriage_return() { + let line = overview_line( + "Her first album took her seven years to finish.\r", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["Her first album took her seven years to finish."] + ); + } + + #[test] + fn test_overview_line_renders_a_carriage_return_only_line_as_blank() { + let line = overview_line("\r", primary_style()); + + assert_eq!(span_contents(&line), vec![""]); + } + + fn span_contents(line: &Line<'_>) -> Vec { + line + .spans + .iter() + .map(|span| span.content.to_string()) + .collect() + } + + mod snapshot_tests { + use crate::models::ScrollableText; + use crate::models::servarr_data::lidarr::modals::ArtistOverviewModal; + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_artist_overview_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_scrolled() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + app + .data + .lidarr_data + .artist_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_an_artist_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + app.data.lidarr_data.artist_overview_modal = Some(ArtistOverviewModal { + overview: ScrollableText::with_string(String::new()), + }); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_nothing_when_the_artist_overview_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + app.data.lidarr_data.artist_overview_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_with_zero_artists() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artists = StatefulTable::default(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "some interesting description of the artist"); + } + } +} diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs index c2505e50..579a66e9 100644 --- a/src/ui/lidarr_ui/library/library_ui_tests.rs +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -4,9 +4,9 @@ mod tests { use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ADD_ARTIST_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, - DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS, - TRACK_DETAILS_BLOCKS, + ADD_ARTIST_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, + ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, + LIBRARY_BLOCKS, TRACK_DETAILS_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style}; @@ -23,6 +23,7 @@ mod tests { library_ui_blocks.extend(EDIT_ARTIST_BLOCKS); library_ui_blocks.extend(ADD_ARTIST_BLOCKS); library_ui_blocks.extend(ARTIST_DETAILS_BLOCKS); + library_ui_blocks.extend(ARTIST_OVERVIEW_BLOCKS); library_ui_blocks.extend(ALBUM_DETAILS_BLOCKS); library_ui_blocks.extend(TRACK_DETAILS_BLOCKS); diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index 4f981aa8..2100d8de 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -31,6 +31,7 @@ use crate::{ mod add_artist_ui; mod album_details_ui; mod artist_details_ui; +mod artist_overview_ui; mod delete_album_ui; mod delete_artist_ui; mod edit_artist_ui; diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap index b94d5baf..1f04b0d5 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap index 77e46b0f..c4f8c402 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ - │Size on Disk: 0.00 GB │Source Title: Test source title │ │ + │Albums: 1 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ + │Tracks: 15/15 │Source Title: Test source title │ │ │╭ Artist Details ─────────────│Event Type: grabbed │───────────────────────────────╮│ ││ Albums │ History │ Manual Sear│Quality: Lossless │ ││ ││───────────────────────────────│Date: 2023-01-01 00:00:00 UTC │───────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap index 6dc4f79a..3bca44cb 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap index b9881f68..298827c7 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap index e6361697..53cf5840 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │ + │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ + │Tracks: 15/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap index 773f42d5..88ba11f2 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │ + │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ + │Tracks: 15/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap index 3dc5c230..a99b6d48 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │ + │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ + │Tracks: 15/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap index e384a505..b53ec9e0 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap index b3354746..98620f9a 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap index 4d2c9e62..a1239a69 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -16,10 +17,9 @@ expression: output │Path: /nfs/music/test-artist │ │Quality Profile: Lossless │ │Metadata Profile: Standard │ - │Monitored: Yes │ - │Albums: 1 ╭───────────────── Download Rejected Release ──────────────────╮ │ - │Tracks: 15/15 │ Do you really want to download the rejected release: Test │ │ - │Size on Disk: 0.00 GB │ Release? │ │ + │Monitored: Yes ╭───────────────── Download Rejected Release ──────────────────╮ │ + │Albums: 1 │ Do you really want to download the rejected release: Test │ │ + │Tracks: 15/15 │ Release? │ │ │╭ Artist Details ──────────────────────│ │───────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││────────────────────────────────────────│Rejection reasons: │───────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap index 07ceddf4..85425a7a 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap index 684e4f66..e17c8acf 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap index 93b238ec..a710ffe3 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap index a940bc30..6e179e3f 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap index 4c2d8f7d..b9ae5e1d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap index 42e9e6a9..db7de871 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap index 929cfa02..8ba046a1 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │ + │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ + │Tracks: 15/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap index 2876cdfa..1d772726 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │ + │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ + │Tracks: 15/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap index b717f47f..91627206 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │ + │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ + │Tracks: 15/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap new file mode 100644 index 00000000..61d7a007 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs +expression: output +--- + + + + ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Artist: Alex │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ │ + │Disambiguat│some interesting description of the artist │ │ + │Type: Perso│ │ │ + │Status: Con│She was born in Madison, Wisconsin: a city she has never really left. │ │ + │Genres: sou│ │ │ + │Rating: 84%│Her first album took her seven years to finish. │ │ + │Path: /nfs/│ │ │ + │Quality Pro│ │ │ + │Metadata Pr│ │ │ + │Monitored: │ │ │ + │Albums: 1 │ │ │ + │Tracks: 15/│ │ │ + │╭ Artist D│ │───────────╮│ + ││ Albums │ │ │ ││ + ││──────────│ │───────────││ + ││ Monitor│ │ ││ + ││=> 🏷 │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap index e6361697..53cf5840 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │ + │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ + │Tracks: 15/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap index 6d7f6e9f..00159b88 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭───────────────────── Delete Album ──────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you really want to delete the album: │ │ + │Albums: 1 ╭───────────────────── Delete Album ──────────────────────╮ │ + │Tracks: 15/15 │ Do you really want to delete the album: │ │ │╭ Artist Details ───────────────────────│ Test Album? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap index 929cfa02..8ba046a1 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │ + │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ + │Tracks: 15/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap index 89ee9563..ac41b703 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap index b9881f68..298827c7 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap index 77e46b0f..c4f8c402 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ - │Size on Disk: 0.00 GB │Source Title: Test source title │ │ + │Albums: 1 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ + │Tracks: 15/15 │Source Title: Test source title │ │ │╭ Artist Details ─────────────│Event Type: grabbed │───────────────────────────────╮│ ││ Albums │ History │ Manual Sear│Quality: Lossless │ ││ ││───────────────────────────────│Date: 2023-01-01 00:00:00 UTC │───────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap index 896b7068..61c2abde 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap index 896b7068..61c2abde 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap index 896b7068..61c2abde 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap index 896b7068..61c2abde 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders.snap new file mode 100644 index 00000000..8ae8b8fe --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │some interesting description of the artist │ + │ │ + │She was born in Madison, Wisconsin: a city she has never really left. │ + │ │ + │Her first album took her seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_an_artist_with_no_overview.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_an_artist_with_no_overview.snap new file mode 100644 index 00000000..de5b377b --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_an_artist_with_no_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_loading.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_loading.snap new file mode 100644 index 00000000..837c92bb --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_nothing_when_the_artist_overview_modal_is_undefined.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_nothing_when_the_artist_overview_modal_is_undefined.snap new file mode 100644 index 00000000..767e0ef9 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_nothing_when_the_artist_overview_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_scrolled.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_scrolled.snap new file mode 100644 index 00000000..f5193931 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_scrolled.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │She was born in Madison, Wisconsin: a city she has never really left. │ + │ │ + │Her first album took her seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap index 1da6ab43..ee98f911 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap @@ -7,8 +7,9 @@ expression: output => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ - │Disambiguation: Ame╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to fini╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Disambiguation: Ame│ │ │ │Type: Person │ │ │ │Status: Continuing │ │ │ │Genres: soundtrack │ │ │ @@ -16,10 +17,9 @@ expression: output │Path: /nfs/music/te│ │ │ │Quality Profile: Lo│ │ │ │Metadata Profile: S│ │ │ - │Monitored: Yes │ │ │ - │Albums: 1 │ ╭───╮ │ │ - │Tracks: 15/15 │ Monitored: │ ✔ │ │ │ - │Size on Disk: 0.00 │ ╰───╯ │ │ + │Monitored: Yes │ ╭───╮ │ │ + │Albums: 1 │ Monitored: │ ✔ │ │ │ + │Tracks: 15/15 │ ╰───╯ │ │ │╭ Artist Details │ ╭─────────────────────────────────────────────────╮ │────────────────────╮│ ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap index f27ce3cf..993c4969 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap @@ -7,8 +7,9 @@ expression: output => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ - │Disambiguation: Ame╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to fini╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Disambiguation: Ame│ │ │ │Type: Person │ │ │ │Status: Continuing │ │ │ │Genres: soundtrack │ │ │ @@ -16,10 +17,9 @@ expression: output │Path: /nfs/music/te│ │ │ │Quality Profile: Lo│ │ │ │Metadata Profile: S│ │ │ - │Monitored: Yes │ │ │ - │Albums: 1 │ ╭───╮ │ │ - │Tracks: 15/15 │ Monitored: │ ✔ │ │ │ - │Size on Disk: 0.00 │ ╰───╯ │ │ + │Monitored: Yes │ ╭───╮ │ │ + │Albums: 1 │ Monitored: │ ✔ │ │ │ + │Tracks: 15/15 │ ╰───╯ │ │ │╭ Artist Details │ ╭─────────────────────────────────────────────────╮ │────────────────────╮│ ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap index b510a18b..7545a8fe 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap @@ -7,8 +7,9 @@ expression: output => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ - │Disambiguation: Ame╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to fini╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Disambiguation: Ame│ │ │ │Type: Person │ │ │ │Status: Continuing │ │ │ │Genres: soundtrack │ │ │ @@ -16,10 +17,9 @@ expression: output │Path: /nfs/music/te│ │ │ │Quality Profile: Lo│ │ │ │Metadata Profile: S│ │ │ - │Monitored: Yes │ │ │ - │Albums: 1 │ ╭───╮ │ │ - │Tracks: 15/15 │ Monitored: │ ✔ │ │ │ - │Size on Disk: 0.00 │ ╰───╯ │ │ + │Monitored: Yes │ ╭───╮ │ │ + │Albums: 1 │ Monitored: │ ✔ │ │ │ + │Tracks: 15/15 │ ╰───╯ │ │ │╭ Artist Details │ ╭─────────────────────────────────────────────────╮ │────────────────────╮│ ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap index b60cb0f9..5ff3575d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap @@ -7,7 +7,8 @@ expression: output => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -19,7 +20,6 @@ expression: output │Monitored: Yes │ │Albums: 1 │ │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ From b3ce15a564c1b3cfda4ce4224ddced7c63d2e7a6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 21:31:25 -0600 Subject: [PATCH 106/170] feat(sonarr): add a scrollable overview popup to series details A series' overview renders as one line of a description box that does not scroll, so a long overview overflows with no way to read the rest. Pressing v on the series details Seasons tab now opens a popup holding the full overview. The popup owns its own ScrollableText, built fresh from the series' overview when it opens and torn down on Esc so a reopened popup starts at the top. Scrolling mutates only that owned state. The background paragraph renders the Series record's overview, which carries no scroll offset, so it cannot scroll along with the popup. Cite the existing view keybinding from the series details context clues and give the popup its own clue set so it does not inherit the hints of the view underneath. Enrich the shared series fixture to a multi-paragraph overview, and the raw response fixture that encodes the same series, so a scrolled popup is distinguishable from an unscrolled one. --- src/app/sonarr/sonarr_context_clues.rs | 16 +- src/app/sonarr/sonarr_context_clues_tests.rs | 34 +- .../library/library_handler_tests.rs | 2 + src/handlers/sonarr_handlers/library/mod.rs | 1 + .../library/series_details_handler.rs | 38 +- .../library/series_details_handler_tests.rs | 175 +++++++- .../library/series_overview_handler.rs | 109 +++++ .../library/series_overview_handler_tests.rs | 393 ++++++++++++++++++ .../sonarr_handler_test_utils.rs | 9 +- src/models/servarr_data/sonarr/modals.rs | 6 + .../servarr_data/sonarr/modals_tests.rs | 9 +- src/models/servarr_data/sonarr/sonarr_data.rs | 12 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 15 +- .../sonarr_network_test_utils.rs | 11 +- src/ui/sonarr_ui/library/library_ui_tests.rs | 3 +- src/ui/sonarr_ui/library/mod.rs | 1 + src/ui/sonarr_ui/library/series_details_ui.rs | 6 + .../library/series_details_ui_tests.rs | 41 ++ .../sonarr_ui/library/series_overview_ui.rs | 87 ++++ .../library/series_overview_ui_tests.rs | 205 +++++++++ ...__edit_series_EditSeriesConfirmPrompt.snap | 4 +- ...EditSeriesConfirmPrompt_SeriesDetails.snap | 6 +- ...t_tests__edit_series_EditSeriesPrompt.snap | 4 +- ...series_EditSeriesPrompt_SeriesDetails.snap | 6 +- ...eries_EditSeriesSelectLanguageProfile.snap | 4 +- ...esSelectLanguageProfile_SeriesDetails.snap | 6 +- ...series_EditSeriesSelectQualityProfile.snap | 4 +- ...iesSelectQualityProfile_SeriesDetails.snap | 6 +- ...dit_series_EditSeriesSelectSeriesType.snap | 4 +- ...tSeriesSelectSeriesType_SeriesDetails.snap | 6 +- ...ry_ui_renders_edit_series_over_series.snap | 4 +- ...ui_renders_series_details_over_series.snap | 2 +- ...s__empty_series_details_SeriesDetails.snap | 2 +- ...s__empty_series_details_SeriesHistory.snap | 2 +- ...y_series_details_SeriesHistoryDetails.snap | 2 +- ..._loading_series_details_SeriesDetails.snap | 2 +- ..._loading_series_details_SeriesHistory.snap | 2 +- ..._ui_AutomaticallySearchSeriesPrompt_0.snap | 2 +- ..._ui_AutomaticallySearchSeriesPrompt_1.snap | 2 +- ...details_ui_FilterSeriesHistoryError_1.snap | 2 +- ...ries_details_ui_FilterSeriesHistory_1.snap | 2 +- ...series_details_ui_SearchSeasonError_0.snap | 2 +- ...sts__series_details_ui_SearchSeason_0.snap | 2 +- ...details_ui_SearchSeriesHistoryError_1.snap | 2 +- ...ries_details_ui_SearchSeriesHistory_1.snap | 2 +- ...ts__series_details_ui_SeriesDetails_0.snap | 2 +- ...ies_details_ui_SeriesHistoryDetails_1.snap | 2 +- ..._details_ui_SeriesHistorySortPrompt_1.snap | 2 +- ...ts__series_details_ui_SeriesHistory_1.snap | 2 +- ...etails_ui_UpdateAndScanSeriesPrompt_0.snap | 2 +- ...etails_ui_UpdateAndScanSeriesPrompt_1.snap | 2 +- ...s_series_overview_over_series_details.snap | 52 +++ ...hot_tests__series_overview_ui_renders.snap | 48 +++ ..._ui_renders_a_series_with_no_overview.snap | 48 +++ ...s__series_overview_ui_renders_loading.snap | 48 +++ ...he_series_overview_modal_is_undefined.snap | 5 + ...__series_overview_ui_renders_scrolled.snap | 48 +++ 57 files changed, 1454 insertions(+), 62 deletions(-) create mode 100644 src/handlers/sonarr_handlers/library/series_overview_handler.rs create mode 100644 src/handlers/sonarr_handlers/library/series_overview_handler_tests.rs create mode 100644 src/ui/sonarr_ui/library/series_overview_ui.rs create mode 100644 src/ui/sonarr_ui/library/series_overview_ui_tests.rs create mode 100644 src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap create mode 100644 src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders.snap create mode 100644 src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_a_series_with_no_overview.snap create mode 100644 src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_loading.snap create mode 100644 src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_nothing_when_the_series_overview_modal_is_undefined.snap create mode 100644 src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_scrolled.snap diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index fb5b46c6..1ed267a0 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -7,7 +7,7 @@ use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ ADD_SERIES_BLOCKS, ActiveSonarrBlock, DELETE_SERIES_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, SEASON_DETAILS_BLOCKS, - SERIES_DETAILS_BLOCKS, + SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, }; #[cfg(test)] @@ -39,7 +39,7 @@ pub static SERIES_CONTEXT_CLUES: [ContextClue; 11] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; -pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ +pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 9] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, @@ -56,6 +56,15 @@ pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ DEFAULT_KEYBINDINGS.auto_search, DEFAULT_KEYBINDINGS.auto_search.desc, ), + (DEFAULT_KEYBINDINGS.view, "view overview"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static SERIES_OVERVIEW_CONTEXT_CLUES: [ContextClue; 2] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; @@ -178,6 +187,9 @@ impl ContextClueProvider for SonarrContextClueProvider { .sonarr_data .series_info_tabs .get_active_route_contextual_help(), + _ if SERIES_OVERVIEW_BLOCKS.contains(&active_sonarr_block) => { + Some(&SERIES_OVERVIEW_CONTEXT_CLUES) + } _ if SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block) => app .data .sonarr_data diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 6f3e4322..09479525 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -16,7 +16,7 @@ mod tests { ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, - SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES, + SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES, SERIES_OVERVIEW_CONTEXT_CLUES, }, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; @@ -187,6 +187,10 @@ mod tests { DEFAULT_KEYBINDINGS.auto_search.desc ) ); + assert_some_eq_x!( + series_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.view, "view overview") + ); assert_some_eq_x!( series_details_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) @@ -194,6 +198,24 @@ mod tests { assert_none!(series_details_context_clues_iter.next()); } + #[test] + fn test_series_overview_context_clues() { + let mut series_overview_context_clues_iter = SERIES_OVERVIEW_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + series_overview_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + series_overview_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(series_overview_context_clues_iter.next()); + } + #[test] fn test_season_details_context_clues() { let mut season_details_context_clues_iter = SEASON_DETAILS_CONTEXT_CLUES.iter(); @@ -442,6 +464,16 @@ mod tests { assert_some_eq_x!(context_clues, expected_context_clues); } + #[test] + fn test_sonarr_context_clue_provider_series_overview_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let context_clues = SonarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &SERIES_OVERVIEW_CONTEXT_CLUES); + } + #[rstest] #[case(0, ActiveSonarrBlock::SeasonDetails, &SEASON_DETAILS_CONTEXT_CLUES)] #[case(1, ActiveSonarrBlock::SeasonHistory, &SEASON_HISTORY_CONTEXT_CLUES)] diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 712168b2..0a605790 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -17,6 +17,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ADD_SERIES_BLOCKS, ActiveSonarrBlock, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, LIBRARY_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, + SERIES_OVERVIEW_BLOCKS, }; use crate::models::sonarr_models::{Series, SeriesStatistics, SeriesStatus, SeriesType}; use crate::test_handler_delegation; @@ -891,6 +892,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(SERIES_OVERVIEW_BLOCKS); library_handler_blocks.extend(SEASON_DETAILS_BLOCKS); library_handler_blocks.extend(EPISODE_DETAILS_BLOCKS); diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index 01934a4e..ab8837ec 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -36,6 +36,7 @@ mod episode_details_handler; mod library_handler_tests; mod season_details_handler; mod series_details_handler; +mod series_overview_handler; pub(super) struct LibraryHandler<'a, 'b> { key: Key, diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs index f5132ba8..47faeafe 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -1,14 +1,16 @@ +use super::series_overview_handler::SeriesOverviewHandler; use crate::app::App; use crate::event::Key; use crate::handlers::sonarr_handlers::history::history_sorting_options; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; +use crate::models::servarr_data::sonarr::modals::SeriesOverviewModal; 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, Route}; +use crate::models::{BlockSelectionState, Route, ScrollableText}; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] @@ -39,10 +41,36 @@ impl SeriesDetailsHandler<'_, '_> { fn extract_series_id(&self) -> i64 { self.app.data.sonarr_data.series.current_selection().id } + + fn build_series_overview_modal(&mut self) { + let overview = self + .app + .data + .sonarr_data + .series + .current_selection() + .overview + .clone() + .unwrap_or_default(); + + self.app.data.sonarr_data.series_overview_modal = Some(SeriesOverviewModal { + overview: ScrollableText::with_string(overview), + }); + } } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler<'a, 'b> { fn handle(&mut self) { + if SeriesOverviewHandler::accepts(self.active_sonarr_block) { + return SeriesOverviewHandler::new( + self.key, + self.app, + self.active_sonarr_block, + self._context, + ) + .handle(); + } + let season_table_handling_config = TableHandlingConfig::new(ActiveSonarrBlock::SeriesDetails.into()) .searching_block(ActiveSonarrBlock::SearchSeason.into()) @@ -85,7 +113,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler } fn accepts(active_block: ActiveSonarrBlock) -> bool { - SERIES_DETAILS_BLOCKS.contains(&active_block) + SeriesOverviewHandler::accepts(active_block) || SERIES_DETAILS_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { @@ -276,6 +304,12 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler self.app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); } + _ if matches_key!(view, key) => { + self.build_series_overview_modal(); + self + .app + .push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + } _ if matches_key!(toggle_monitoring, key) => { self.app.data.sonarr_data.prompt_confirm = true; let (series_id, season_number) = self.extract_series_id_season_number_tuple(); 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 152966a4..33d9c6f1 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs @@ -9,11 +9,12 @@ mod tests { use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; use crate::handlers::sonarr_handlers::sonarr_handler_test_utils::utils::{season, series}; use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, + ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, }; use crate::models::sonarr_models::Season; use crate::models::sonarr_models::SonarrHistoryItem; use crate::models::stateful_table::StatefulTable; + use crate::test_handler_delegation; use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; @@ -313,11 +314,12 @@ mod tests { mod test_handle_key_char { use super::*; + use crate::handlers::sonarr_handlers::library::series_overview_handler::SeriesOverviewHandler; use crate::models::servarr_data::sonarr::sonarr_data::SonarrData; use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; use crate::models::sonarr_models::{Series, SeriesType}; use crate::network::sonarr_network::SonarrEvent; - use crate::{assert_navigation_popped, test_edit_series_key}; + use crate::{assert_modal_present, assert_navigation_popped, test_edit_series_key}; use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::Number; use strum::IntoEnumIterator; @@ -355,6 +357,161 @@ mod tests { assert_modal_absent!(app.data.sonarr_data.edit_series_modal); } + #[test] + fn test_series_details_view_key_opens_the_series_overview() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SeriesOverview.into()); + assert_modal_present!(app.data.sonarr_data.series_overview_modal); + } + + #[test] + fn test_series_details_view_key_builds_the_overview_from_the_selected_series() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + let overview = &app + .data + .sonarr_data + .series_overview_modal + .as_ref() + .unwrap() + .overview; + assert_eq!(overview.offset, 0); + assert_eq!( + overview.get_text(), + app + .data + .sonarr_data + .series + .current_selection() + .overview + .clone() + .unwrap() + ); + } + + #[test] + fn test_series_details_view_key_for_a_series_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + let mut series = app.data.sonarr_data.series.current_selection().clone(); + series.overview = None; + app.data.sonarr_data.series.set_items(vec![series]); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SeriesOverview.into()); + assert_str_eq!( + app + .data + .sonarr_data + .series_overview_modal + .as_ref() + .unwrap() + .overview + .get_text(), + "" + ); + } + + #[test] + fn test_series_details_view_key_reopens_the_series_overview_at_the_top() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SeriesOverview.into()); + assert_eq!( + app + .data + .sonarr_data + .series_overview_modal + .as_ref() + .unwrap() + .overview + .offset, + 0 + ); + } + + #[test] + fn test_series_details_view_key_no_op_when_not_ready() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + assert_modal_absent!(app.data.sonarr_data.series_overview_modal); + } + #[test] fn test_toggle_monitoring_key() { let mut app = App::test_default(); @@ -578,8 +735,11 @@ mod tests { #[test] fn test_series_details_handler_accepts() { + let mut series_details_blocks = SERIES_DETAILS_BLOCKS.to_vec(); + series_details_blocks.extend(SERIES_OVERVIEW_BLOCKS); + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { - if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) { + if series_details_blocks.contains(&active_sonarr_block) { assert!(SeriesDetailsHandler::accepts(active_sonarr_block)); } else { assert!(!SeriesDetailsHandler::accepts(active_sonarr_block)); @@ -587,6 +747,15 @@ mod tests { }); } + #[test] + fn test_delegates_series_overview_blocks_to_series_overview_handler() { + test_handler_delegation!( + SeriesDetailsHandler, + ActiveSonarrBlock::SeriesDetails, + ActiveSonarrBlock::SeriesOverview + ); + } + #[rstest] fn test_series_details_handler_ignore_special_keys( #[values(true, false)] ignore_special_keys_for_textbox_input: bool, diff --git a/src/handlers/sonarr_handlers/library/series_overview_handler.rs b/src/handlers/sonarr_handlers/library/series_overview_handler.rs new file mode 100644 index 00000000..b7819990 --- /dev/null +++ b/src/handlers/sonarr_handlers/library/series_overview_handler.rs @@ -0,0 +1,109 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::matches_key; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_OVERVIEW_BLOCKS}; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "series_overview_handler_tests.rs"] +mod series_overview_handler_tests; + +pub(super) struct SeriesOverviewHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl SeriesOverviewHandler<'_, '_> { + fn overview(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .sonarr_data + .series_overview_modal + .as_mut() + .expect("Series overview modal is undefined") + .overview + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesOverviewHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + SERIES_OVERVIEW_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> SeriesOverviewHandler<'a, 'b> { + SeriesOverviewHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + self.app.data.sonarr_data.series_overview_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.overview().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.overview().scroll_down(); + } + + fn handle_home(&mut self) { + self.overview().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.overview().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) { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.series_overview_modal = None; + } + + fn handle_char_key_event(&mut self) { + if matches_key!(refresh, self.key) { + self + .app + .pop_and_push_navigation_stack(self.active_sonarr_block.into()); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/sonarr_handlers/library/series_overview_handler_tests.rs b/src/handlers/sonarr_handlers/library/series_overview_handler_tests.rs new file mode 100644 index 00000000..97df075e --- /dev/null +++ b/src/handlers/sonarr_handlers/library/series_overview_handler_tests.rs @@ -0,0 +1,393 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::sonarr_handlers::library::series_overview_handler::SeriesOverviewHandler; + use crate::models::ScrollableText; + use crate::models::servarr_data::sonarr::modals::SeriesOverviewModal; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SERIES_OVERVIEW_BLOCKS, + }; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_series_overview_scroll_up() { + let mut app = app_with_series_overview(); + set_offset(&mut app, 2); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_series_overview_scroll_up_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + set_offset(&mut app, 2); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 2); + } + + #[test] + fn test_series_overview_scroll_down() { + let mut app = app_with_series_overview(); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_series_overview_scroll_down_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_series_overview_home() { + let mut app = app_with_series_overview(); + set_offset(&mut app, 3); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + + #[test] + fn test_series_overview_end() { + let mut app = app_with_series_overview(); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + } + + #[test] + fn test_series_overview_home_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + set_offset(&mut app, 3); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 3); + } + + #[test] + fn test_series_overview_end_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_navigation_popped}; + + #[test] + fn test_series_overview_esc_tears_down_the_series_overview_modal() { + let mut app = app_with_series_overview(); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveSonarrBlock::SeriesDetails.into()); + assert_modal_absent!(app.data.sonarr_data.series_overview_modal); + } + + #[test] + fn test_series_overview_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_series_overview(); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveSonarrBlock::SeriesDetails.into()); + assert_modal_absent!(app.data.sonarr_data.series_overview_modal); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_series_overview_refresh_key() { + let mut app = app_with_series_overview(); + app.is_routing = false; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SeriesOverview.into()); + assert!(app.is_routing); + } + + #[test] + fn test_series_overview_refresh_key_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + app.is_loading = true; + app.is_routing = false; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert!(!app.is_routing); + } + } + + #[test] + fn test_scrolling_the_overview_does_not_mutate_the_series_record() { + let mut app = app_with_series_overview(); + let overview_before = app + .data + .sonarr_data + .series + .current_selection() + .overview + .clone(); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + assert_eq!( + app.data.sonarr_data.series.current_selection().overview, + overview_before + ); + } + + #[test] + fn test_series_overview_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SERIES_OVERVIEW_BLOCKS.contains(&active_sonarr_block) { + assert!( + SeriesOverviewHandler::accepts(active_sonarr_block), + "{active_sonarr_block} is not accepted by the SeriesOverviewHandler" + ); + } else { + assert!( + !SeriesOverviewHandler::accepts(active_sonarr_block), + "{active_sonarr_block} is wrongly accepted by the SeriesOverviewHandler" + ); + } + }); + } + + #[test] + fn test_series_overview_handler_dispatches_no_network_events() { + let mut app = app_with_series_overview(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + SeriesOverviewHandler::new(key, &mut app, ActiveSonarrBlock::SeriesOverview, None).handle(); + + assert_none!(app.data.sonarr_data.prompt_confirm_action); + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + #[test] + fn test_series_overview_handler_is_not_ready_when_loading() { + let mut app = app_with_series_overview(); + app.is_loading = true; + + let handler = SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_series_overview_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = app_with_series_overview(); + app.data.sonarr_data.series_overview_modal = None; + + let handler = SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_series_overview_handler_is_ready() { + let mut app = app_with_series_overview(); + + let handler = SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_series_overview_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_series_overview(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_series_overview() -> App<'static> { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = Some(SeriesOverviewModal { + overview: ScrollableText::with_string( + "Blah blah blah\r\n\ + \r\n\ + It was filmed in Madison, Wisconsin: a city the crew never really left.\r\n\ + \r\n\ + The first season took seven years to finish.\r\n" + .to_owned(), + ), + }); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + app + } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .sonarr_data + .series_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .sonarr_data + .series_overview_modal + .as_mut() + .unwrap() + .overview + .offset = offset; + } +} diff --git a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs index 39e54352..fca77ae4 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs @@ -351,7 +351,14 @@ pub(in crate::handlers::sonarr_handlers) mod utils { title: "Test".to_owned().into(), status: SeriesStatus::Continuing, ended: false, - overview: Some("Blah blah blah".to_owned()), + overview: Some( + "Blah blah blah\r\n\ + \r\n\ + It was filmed in Madison, Wisconsin: a city the crew never really left.\r\n\ + \r\n\ + The first season took seven years to finish.\r\n" + .to_owned(), + ), network: Some("HBO".to_owned()), seasons: Some(vec![season()]), year: 2022, diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index d1d12d8c..867aa516 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -303,3 +303,9 @@ impl Default for SeasonDetailsModal { } } } + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct SeriesOverviewModal { + pub overview: ScrollableText, +} diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index 0a867c84..b8a9616a 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -11,7 +11,7 @@ mod tests { SELECTABLE_EPISODE_DETAILS_CONTEXT_CLUES, }; use crate::models::servarr_data::sonarr::modals::{ - EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, + EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, SeriesOverviewModal, }; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{Indexer, IndexerField}; @@ -428,4 +428,11 @@ mod tests { None ); } + + #[test] + fn test_series_overview_modal_default() { + let series_overview_modal = SeriesOverviewModal::default(); + + assert_is_empty!(series_overview_modal.overview); + } } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index c4702807..cc20151b 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -1,4 +1,4 @@ -use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal}; +use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal, SeriesOverviewModal}; use crate::{ app::{ context_clues::{ @@ -89,6 +89,7 @@ pub struct SonarrData<'a> { pub series: StatefulTable, pub series_history: Option>, pub series_info_tabs: TabState, + pub series_overview_modal: Option, pub start_time: DateTime, pub tags_map: BiMap, pub tasks: StatefulTable, @@ -172,6 +173,7 @@ impl<'a> Default for SonarrData<'a> { selected_block: BlockSelectionState::default(), series: StatefulTable::default(), series_history: None, + series_overview_modal: None, start_time: DateTime::default(), tags_map: BiMap::default(), tasks: StatefulTable::default(), @@ -347,6 +349,10 @@ impl SonarrData<'_> { series_history.search = Some("series history search".into()); series_history.filter = Some("series history filter".into()); + let series_overview_modal = SeriesOverviewModal { + overview: ScrollableText::with_string(series().overview.clone().unwrap_or_default()), + }; + let mut sonarr_data = SonarrData { add_list_exclusion: true, add_searched_series: Some(add_searched_series), @@ -364,6 +370,7 @@ impl SonarrData<'_> { quality_profile_map: quality_profile_map(), season_details_modal: Some(season_details_modal), series_history: Some(series_history), + series_overview_modal: Some(series_overview_modal), start_time: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()), tags_map: tags_map(), updates: updates(), @@ -503,6 +510,7 @@ pub enum ActiveSonarrBlock { SeriesHistory, SeriesHistoryDetails, SeriesHistorySortPrompt, + SeriesOverview, SeriesSortPrompt, System, SystemLogs, @@ -542,6 +550,8 @@ pub static SERIES_DETAILS_BLOCKS: [ActiveSonarrBlock; 12] = [ ActiveSonarrBlock::SeriesHistoryDetails, ]; +pub static SERIES_OVERVIEW_BLOCKS: [ActiveSonarrBlock; 1] = [ActiveSonarrBlock::SeriesOverview]; + pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 15] = [ ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 25c5756b..4cba45d6 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -171,6 +171,7 @@ mod tests { assert_eq!(sonarr_data.selected_block, BlockSelectionState::default()); assert_is_empty!(sonarr_data.series); assert_none!(sonarr_data.series_history); + assert_none!(sonarr_data.series_overview_modal); assert_eq!(sonarr_data.start_time, >::default()); assert_is_empty!(sonarr_data.tags_map); assert_is_empty!(sonarr_data.tasks); @@ -289,7 +290,8 @@ mod tests { EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, EPISODE_DETAILS_BLOCKS, HISTORY_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, LIBRARY_BLOCKS, - ROOT_FOLDERS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS, + ROOT_FOLDERS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, + SYSTEM_DETAILS_BLOCKS, }; #[test] @@ -671,6 +673,17 @@ mod tests { assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistoryDetails)); } + #[test] + fn test_series_details_blocks_excludes_series_overview() { + assert!(!SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesOverview)); + } + + #[test] + fn test_series_overview_blocks_contents() { + assert_eq!(SERIES_OVERVIEW_BLOCKS.len(), 1); + assert!(SERIES_OVERVIEW_BLOCKS.contains(&ActiveSonarrBlock::SeriesOverview)); + } + #[test] fn test_season_details_blocks_contents() { assert_eq!(SEASON_DETAILS_BLOCKS.len(), 15); diff --git a/src/network/sonarr_network/sonarr_network_test_utils.rs b/src/network/sonarr_network/sonarr_network_test_utils.rs index 0a9fab3d..ac60fa57 100644 --- a/src/network/sonarr_network/sonarr_network_test_utils.rs +++ b/src/network/sonarr_network/sonarr_network_test_utils.rs @@ -19,7 +19,7 @@ pub mod test_utils { "title": "Test", "status": "continuing", "ended": false, - "overview": "Blah blah blah", + "overview": "Blah blah blah\r\n\r\nIt was filmed in Madison, Wisconsin: a city the crew never really left.\r\n\r\nThe first season took seven years to finish.\r\n", "network": "HBO", "seasons": [ { @@ -319,7 +319,14 @@ pub mod test_utils { title: "Test".to_owned().into(), status: SeriesStatus::Continuing, ended: false, - overview: Some("Blah blah blah".to_owned()), + overview: Some( + "Blah blah blah\r\n\ + \r\n\ + It was filmed in Madison, Wisconsin: a city the crew never really left.\r\n\ + \r\n\ + The first season took seven years to finish.\r\n" + .to_owned(), + ), network: Some("HBO".to_owned()), seasons: Some(vec![season()]), year: 2022, diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 0f73c611..edddd0b1 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::{ ADD_SERIES_BLOCKS, ActiveSonarrBlock, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, - EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, + EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, }; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, @@ -27,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(SERIES_OVERVIEW_BLOCKS); library_ui_blocks.extend(SEASON_DETAILS_BLOCKS); library_ui_blocks.extend(EPISODE_DETAILS_BLOCKS); diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 2cef8648..eb179001 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -38,6 +38,7 @@ mod episode_details_ui; #[path = "library_ui_tests.rs"] mod library_ui_tests; mod season_details_ui; +mod series_overview_ui; pub(super) struct LibraryUi; diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 3958763c..7d4ec9d3 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -14,6 +14,7 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES use crate::models::sonarr_models::{Season, SeasonStatistics, SonarrHistoryItem}; 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::library::series_overview_ui::SeriesOverviewUi; use crate::ui::sonarr_ui::sonarr_ui_utils::create_history_event_details; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ @@ -40,6 +41,7 @@ impl DrawUi for SeriesDetailsUi { }; SeasonDetailsUi::accepts(route) || EpisodeDetailsUi::accepts(route) + || SeriesOverviewUi::accepts(route) || SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) } @@ -107,6 +109,10 @@ impl DrawUi for SeriesDetailsUi { if SeasonDetailsUi::accepts(route) { SeasonDetailsUi::draw(f, app, area); } + + if SeriesOverviewUi::accepts(route) { + SeriesOverviewUi::draw(f, app, 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 85ab39cf..88bcc299 100644 --- a/src/ui/sonarr_ui/library/series_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -5,6 +5,7 @@ mod tests { use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, + SERIES_OVERVIEW_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::sonarr_ui::library::series_details_ui::SeriesDetailsUi; @@ -15,6 +16,7 @@ mod tests { let mut blocks = SERIES_DETAILS_BLOCKS.clone().to_vec(); blocks.extend(SEASON_DETAILS_BLOCKS); blocks.extend(EPISODE_DETAILS_BLOCKS); + blocks.extend(SERIES_OVERVIEW_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if blocks.contains(&active_sonarr_block) { @@ -28,6 +30,7 @@ mod tests { mod snapshot_tests { use crate::models::stateful_table::StatefulTable; use crate::ui::ui_test_utils::test_utils::TerminalSize; + use pretty_assertions::assert_str_eq; use rstest::rstest; use super::*; @@ -134,5 +137,43 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_series_details_ui_renders_series_overview_over_series_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_scrolling_the_series_overview_does_not_scroll_the_series_details_background() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + let before = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesDetailsUi::draw(f, app, f.area()); + }); + + app + .data + .sonarr_data + .series_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 3; + + let after = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesDetailsUi::draw(f, app, f.area()); + }); + + assert_str_eq!(before, after); + assert_contains!(before, "Overview: Blah blah blah"); + } } } diff --git a/src/ui/sonarr_ui/library/series_overview_ui.rs b/src/ui/sonarr_ui/library/series_overview_ui.rs new file mode 100644 index 00000000..06b095d2 --- /dev/null +++ b/src/ui/sonarr_ui/library/series_overview_ui.rs @@ -0,0 +1,87 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::sonarr::sonarr_data::SERIES_OVERVIEW_BLOCKS; +use crate::models::sonarr_models::Series; +use crate::ui::styles::{primary_style, unmonitored_style}; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +#[cfg(test)] +#[path = "series_overview_ui_tests.rs"] +mod series_overview_ui_tests; + +pub(super) struct SeriesOverviewUi; + +impl DrawUi for SeriesOverviewUi { + fn accepts(route: Route) -> bool { + let Route::Sonarr(active_sonarr_block, _) = route else { + return false; + }; + SERIES_OVERVIEW_BLOCKS.contains(&active_sonarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if app.data.sonarr_data.series_overview_modal.is_some() { + let draw_series_overview_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + draw_series_overview(f, app, popup_area); + }; + + draw_popup(f, app, draw_series_overview_popup, Size::Large); + } + } +} + +fn draw_series_overview(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block_centered("Overview"); + + match app.data.sonarr_data.series_overview_modal.as_ref() { + Some(series_overview_modal) if !app.is_loading => { + let style = if app.data.sonarr_data.series.is_empty() { + primary_style() + } else { + style_from_series(app.data.sonarr_data.series.current_selection()) + }; + let overview = &series_overview_modal.overview; + let text = Text::from( + overview + .items + .iter() + .map(|line| overview_line(line, style)) + .collect::>>(), + ); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((overview.offset, 0)); + + f.render_widget(paragraph, area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.sonarr_data.series_overview_modal.is_none(), + block, + ), + area, + ), + } +} + +fn style_from_series(series: &Series) -> Style { + if !series.monitored { + return unmonitored_style(); + } + + primary_style() +} + +fn overview_line(line: &str, style: Style) -> Line<'static> { + Line::from(Span::styled(line.trim_end().to_owned(), style)) +} diff --git a/src/ui/sonarr_ui/library/series_overview_ui_tests.rs b/src/ui/sonarr_ui/library/series_overview_ui_tests.rs new file mode 100644 index 00000000..27e524cd --- /dev/null +++ b/src/ui/sonarr_ui/library/series_overview_ui_tests.rs @@ -0,0 +1,205 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::style::Modifier; + use ratatui::text::Line; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SERIES_OVERVIEW_BLOCKS, + }; + use crate::models::sonarr_models::Series; + use crate::ui::DrawUi; + use crate::ui::sonarr_ui::library::series_overview_ui::{ + SeriesOverviewUi, overview_line, style_from_series, + }; + use crate::ui::styles::{primary_style, unmonitored_style}; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_series_overview_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SERIES_OVERVIEW_BLOCKS.contains(&active_sonarr_block) { + assert!(SeriesOverviewUi::accepts(active_sonarr_block.into())); + } else { + assert!(!SeriesOverviewUi::accepts(active_sonarr_block.into())); + } + }); + } + + #[test] + fn test_style_from_series_monitored() { + let series = Series { + monitored: true, + ..Series::default() + }; + + let style = style_from_series(&series); + + assert_eq!(style, primary_style()); + } + + #[test] + fn test_style_from_series_unmonitored() { + let series = Series { + monitored: false, + ..Series::default() + }; + + let style = style_from_series(&series); + + assert_eq!(style, unmonitored_style()); + } + + #[test] + fn test_overview_line_does_not_split_a_label_from_a_colon_in_the_prose() { + let line = overview_line( + "It was filmed in Madison, Wisconsin: a city the crew never really left.", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["It was filmed in Madison, Wisconsin: a city the crew never really left."] + ); + } + + #[test] + fn test_overview_line_renders_the_prose_without_bolding_it() { + let line = overview_line("Overview: a prose line", primary_style()); + + assert_eq!(line.spans.len(), 1); + assert!(!line.spans[0].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_overview_line_applies_the_given_style() { + let line = overview_line("an unmonitored series", unmonitored_style()); + + assert_eq!(line.spans[0].style.fg, unmonitored_style().fg); + } + + #[test] + fn test_overview_line_trims_a_trailing_carriage_return() { + let line = overview_line( + "The first season took seven years to finish.\r", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["The first season took seven years to finish."] + ); + } + + #[test] + fn test_overview_line_renders_a_carriage_return_only_line_as_blank() { + let line = overview_line("\r", primary_style()); + + assert_eq!(span_contents(&line), vec![""]); + } + + fn span_contents(line: &Line<'_>) -> Vec { + line + .spans + .iter() + .map(|span| span.content.to_string()) + .collect() + } + + mod snapshot_tests { + use crate::models::ScrollableText; + use crate::models::servarr_data::sonarr::modals::SeriesOverviewModal; + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_series_overview_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_scrolled() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + app + .data + .sonarr_data + .series_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_a_series_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + app.data.sonarr_data.series_overview_modal = Some(SeriesOverviewModal { + overview: ScrollableText::with_string(String::new()), + }); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_nothing_when_the_series_overview_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + app.data.sonarr_data.series_overview_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_with_zero_series() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series = StatefulTable::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "Blah blah blah"); + } + } +} diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap index fd06895e..eaad66e7 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap index be8a8fe2..f4d7ba67 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap index fd06895e..eaad66e7 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap index be8a8fe2..f4d7ba67 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap index 9990fb34..ce0dcc5b 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap index f882c48d..e86f0274 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap index 3cc0c3e1..0f225f03 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap index 7f9f5449..91820af6 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap index cd0e5be2..97e1bcc2 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap index 8d22ec94..ee29f2c1 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap index 7fe1a718..4689b67e 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap index 81ab929e..86812c78 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap @@ -7,7 +7,7 @@ expression: output => Test 2022 HBO Continuin TV-MA Standard Bluray-1080p English 59.51 GB 🏷 ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap index 8d7f917d..36ccbeb0 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap index 8d7f917d..36ccbeb0 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap index 12609fd0..267fbd35 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap index f7e287f0..0ba89efe 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap index f7e287f0..0ba89efe 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap index 385ee3ea..aa11859a 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap index accf608b..1731bf81 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap index 7e0ca69a..0701b977 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap index 3e37c149..a92caf68 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap index 3bd7da7f..90c6bb9a 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap index a5940730..d9d22377 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap index c213bf22..0343e7b9 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap index afd0fa0e..ebca646d 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap index 1f91c3aa..6d92d0e1 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap index 851a727c..29599c10 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap index 4c982de4..02b958da 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap index 7633027b..263df9ca 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap index 05966110..a77fd126 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap index 2e1e2ee8..d5269529 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap new file mode 100644 index 00000000..6ac1fb17 --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/sonarr_ui/library/series_details_ui_tests.rs +expression: output +--- + + + + ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Title: Test │ + │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Network: HB╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ │ + │Status: Con│Blah blah blah │ │ + │Genres: coo│ │ │ + │Rating: 84%│It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ + │Year: 2022 │ │ │ + │Runtime: 63│The first season took seven years to finish. │ │ + │Path: /nfs/│ │ │ + │Quality Pro│ │ │ + │Language Pr│ │ │ + │Monitored: │ │ │ + │Size on Dis│ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │╭ Series D│ │───────────╮│ + ││ Seasons ││ │ ││ + ││──────────│ │───────────││ + ││ Monitor│ │ ││ + ││=> 🏷 │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders.snap new file mode 100644 index 00000000..da225edf --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │Blah blah blah │ + │ │ + │It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ │ + │The first season took seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_a_series_with_no_overview.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_a_series_with_no_overview.snap new file mode 100644 index 00000000..77a6bd5f --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_a_series_with_no_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_loading.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_loading.snap new file mode 100644 index 00000000..e230d95e --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_nothing_when_the_series_overview_modal_is_undefined.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_nothing_when_the_series_overview_modal_is_undefined.snap new file mode 100644 index 00000000..0c31337c --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_nothing_when_the_series_overview_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_scrolled.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_scrolled.snap new file mode 100644 index 00000000..a7c38a6c --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_scrolled.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ │ + │The first season took seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ From c9244b045903ddfb547ed13b6583477d9e9d9db8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 8 Sep 2026 22:01:03 -0600 Subject: [PATCH 107/170] feat(radarr): make the collection movie overview popup scrollable A collection movie's overview renders in a popup that does not scroll, so a long overview overflows with no way to read the rest. The popup now scrolls. The popup owns its own ScrollableText, built from the selected collection movie's overview when it opens and torn down on Esc so a reopened popup starts at the top. Scrolling mutates only that owned state. The background renders the Collection record's overview and the movies table, neither of which carries a scroll offset, so they cannot scroll along with the popup. The popup also gains the loading and absent fallbacks the rest of the tree already has. Give the collection movie fixture an overview distinct from its collection's, and a multi-paragraph one, so the snapshots can tell the two records apart and a scrolled popup is distinguishable from an unscrolled one. --- .../collections/collection_details_handler.rs | 42 ++- .../collection_details_handler_tests.rs | 95 ++++- .../radarr_handlers/collections/mod.rs | 1 + .../collections/movie_overview_handler.rs | 102 ++++++ .../movie_overview_handler_tests.rs | 345 ++++++++++++++++++ .../radarr_handler_test_utils.rs | 7 +- src/models/servarr_data/radarr/modals.rs | 6 + .../servarr_data/radarr/modals_tests.rs | 9 +- src/models/servarr_data/radarr/radarr_data.rs | 9 +- .../servarr_data/radarr/radarr_data_tests.rs | 1 + .../radarr_collections_network_tests.rs | 12 +- .../radarr_network_test_utils.rs | 9 +- .../collections/collection_details_ui.rs | 48 ++- .../collection_details_ui_tests.rs | 105 +++++- ...ts__snapshot_tests__ViewMovieOverview.snap | 8 +- ...ils_ui_renders_movie_overview_loading.snap | 48 +++ ...vie_overview_when_the_modal_is_absent.snap | 48 +++ ...ls_ui_renders_scrolled_movie_overview.snap | 48 +++ ...sts__AddMoviePrompt_CollectionDetails.snap | 6 +- ...MinimumAvailability_CollectionDetails.snap | 6 +- ...dMovieSelectMonitor_CollectionDetails.snap | 6 +- ...electQualityProfile_CollectionDetails.snap | 6 +- ...vieSelectRootFolder_CollectionDetails.snap | 6 +- ...__AddMovieTagsInput_CollectionDetails.snap | 6 +- 24 files changed, 913 insertions(+), 66 deletions(-) create mode 100644 src/handlers/radarr_handlers/collections/movie_overview_handler.rs create mode 100644 src/handlers/radarr_handlers/collections/movie_overview_handler_tests.rs create mode 100644 src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap create mode 100644 src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap create mode 100644 src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index 92ca215f..a91faef0 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -1,14 +1,16 @@ +use super::movie_overview_handler::MovieOverviewHandler; use crate::app::App; use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::matches_key; +use crate::models::servarr_data::radarr::modals::MovieOverviewModal; use crate::models::servarr_data::radarr::radarr_data::{ ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, }; use crate::models::stateful_table::StatefulTable; -use crate::models::{BlockSelectionState, Route}; +use crate::models::{BlockSelectionState, Route, ScrollableText}; #[cfg(test)] #[path = "collection_details_handler_tests.rs"] @@ -21,10 +23,35 @@ pub(super) struct CollectionDetailsHandler<'a, 'b> { _context: Option, } -impl CollectionDetailsHandler<'_, '_> {} +impl CollectionDetailsHandler<'_, '_> { + fn build_movie_overview_modal(&mut self) { + let overview = self + .app + .data + .radarr_data + .collection_movies + .current_selection() + .overview + .clone(); + + self.app.data.radarr_data.movie_overview_modal = Some(MovieOverviewModal { + overview: ScrollableText::with_string(overview), + }); + } +} impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHandler<'a, 'b> { fn handle(&mut self) { + if MovieOverviewHandler::accepts(self.active_radarr_block) { + return MovieOverviewHandler::new( + self.key, + self.app, + self.active_radarr_block, + self._context, + ) + .handle(); + } + let collection_movies_table_handling_config = TableHandlingConfig::new(ActiveRadarrBlock::CollectionDetails.into()); @@ -98,6 +125,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan .iter() .any(|movie| movie.tmdb_id == tmdb_id) { + self.build_movie_overview_modal(); self .app .push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); @@ -117,13 +145,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan } fn handle_esc(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::CollectionDetails => { - self.app.data.radarr_data.collection_movies = StatefulTable::default(); - self.app.pop_navigation_stack(); - } - ActiveRadarrBlock::ViewMovieOverview => self.app.pop_navigation_stack(), - _ => (), + if self.active_radarr_block == ActiveRadarrBlock::CollectionDetails { + self.app.data.radarr_data.collection_movies = StatefulTable::default(); + self.app.pop_navigation_stack(); } } 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 2f5bdb87..57e8ee54 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs @@ -11,15 +11,18 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::handlers::radarr_handlers::collections::collection_details_handler::CollectionDetailsHandler; use crate::models::radarr_models::CollectionMovie; + use crate::models::servarr_data::radarr::modals::MovieOverviewModal; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; + use crate::test_handler_delegation; mod test_handle_submit { - use crate::assert_navigation_pushed; + use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::collection_movie; use crate::models::BlockSelectionState; use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::ADD_MOVIE_SELECTION_BLOCKS; + use crate::{assert_modal_present, assert_navigation_pushed}; use bimap::BiMap; use pretty_assertions::assert_eq; @@ -143,12 +146,11 @@ mod tests { .data .radarr_data .collection_movies - .set_items(vec![CollectionMovie::default()]); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); + .set_items(vec![collection_movie()]); + app.data.radarr_data.movies.set_items(vec![Movie { + tmdb_id: collection_movie().tmdb_id, + ..Movie::default() + }]); CollectionDetailsHandler::new( SUBMIT_KEY, @@ -159,6 +161,63 @@ mod tests { .handle(); assert_navigation_pushed!(app, ActiveRadarrBlock::ViewMovieOverview.into()); + assert_modal_present!(app.data.radarr_data.movie_overview_modal); + let movie_overview_modal = app.data.radarr_data.movie_overview_modal.as_ref().unwrap(); + assert_eq!(movie_overview_modal.overview.offset, 0); + assert_str_eq!( + movie_overview_modal.overview.get_text(), + collection_movie().overview + ); + } + + #[test] + fn test_collection_details_submit_reopens_the_movie_overview_at_the_top() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()); + app + .data + .radarr_data + .collection_movies + .set_items(vec![collection_movie()]); + app.data.radarr_data.movies.set_items(vec![Movie { + tmdb_id: collection_movie().tmdb_id, + ..Movie::default() + }]); + + CollectionDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveRadarrBlock::CollectionDetails, + None, + ) + .handle(); + CollectionDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + + CollectionDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + CollectionDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveRadarrBlock::CollectionDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::ViewMovieOverview.into()); + assert_eq!(overview_offset(&app), 0); } } @@ -198,6 +257,7 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()); app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + app.data.radarr_data.movie_overview_modal = Some(MovieOverviewModal::default()); CollectionDetailsHandler::new( ESC_KEY, @@ -208,6 +268,7 @@ mod tests { .handle(); assert_navigation_popped!(app, ActiveRadarrBlock::CollectionDetails.into()); + assert_modal_absent!(app.data.radarr_data.movie_overview_modal); } } @@ -277,6 +338,15 @@ mod tests { }); } + #[test] + fn test_delegates_view_movie_overview_to_movie_overview_handler() { + test_handler_delegation!( + CollectionDetailsHandler, + ActiveRadarrBlock::CollectionDetails, + ActiveRadarrBlock::ViewMovieOverview + ); + } + #[rstest] fn test_collection_details_handler_ignore_special_keys( #[values(true, false)] ignore_special_keys_for_textbox_input: bool, @@ -345,4 +415,15 @@ mod tests { assert!(handler.is_ready()); } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .radarr_data + .movie_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } } diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index 4e2e32a5..37bbdccd 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -16,6 +16,7 @@ use crate::network::radarr_network::RadarrEvent; mod collection_details_handler; mod edit_collection_handler; +mod movie_overview_handler; #[cfg(test)] #[path = "collections_handler_tests.rs"] diff --git a/src/handlers/radarr_handlers/collections/movie_overview_handler.rs b/src/handlers/radarr_handlers/collections/movie_overview_handler.rs new file mode 100644 index 00000000..57ca1375 --- /dev/null +++ b/src/handlers/radarr_handlers/collections/movie_overview_handler.rs @@ -0,0 +1,102 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "movie_overview_handler_tests.rs"] +mod movie_overview_handler_tests; + +pub(super) struct MovieOverviewHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + _active_radarr_block: ActiveRadarrBlock, + _context: Option, +} + +impl MovieOverviewHandler<'_, '_> { + fn overview(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .radarr_data + .movie_overview_modal + .as_mut() + .expect("Movie overview modal is undefined") + .overview + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieOverviewHandler<'a, 'b> { + fn accepts(active_block: ActiveRadarrBlock) -> bool { + active_block == ActiveRadarrBlock::ViewMovieOverview + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveRadarrBlock, + _context: Option, + ) -> MovieOverviewHandler<'a, 'b> { + MovieOverviewHandler { + key, + app, + _active_radarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + self.app.data.radarr_data.movie_overview_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.overview().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.overview().scroll_down(); + } + + fn handle_home(&mut self) { + self.overview().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.overview().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) { + self.app.pop_navigation_stack(); + self.app.data.radarr_data.movie_overview_modal = None; + } + + fn handle_char_key_event(&mut self) {} + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/radarr_handlers/collections/movie_overview_handler_tests.rs b/src/handlers/radarr_handlers/collections/movie_overview_handler_tests.rs new file mode 100644 index 00000000..ce345fc3 --- /dev/null +++ b/src/handlers/radarr_handlers/collections/movie_overview_handler_tests.rs @@ -0,0 +1,345 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::radarr_handlers::collections::movie_overview_handler::MovieOverviewHandler; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_movie_overview_scroll_up() { + let mut app = app_with_movie_overview(); + set_offset(&mut app, 2); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_movie_overview_scroll_up_no_op_when_not_ready() { + let mut app = app_with_movie_overview(); + set_offset(&mut app, 2); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 2); + } + + #[test] + fn test_movie_overview_scroll_down() { + let mut app = app_with_movie_overview(); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_movie_overview_scroll_down_no_op_when_not_ready() { + let mut app = app_with_movie_overview(); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_movie_overview_home() { + let mut app = app_with_movie_overview(); + set_offset(&mut app, 3); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + + #[test] + fn test_movie_overview_end() { + let mut app = app_with_movie_overview(); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + } + + #[test] + fn test_movie_overview_home_no_op_when_not_ready() { + let mut app = app_with_movie_overview(); + set_offset(&mut app, 3); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 3); + } + + #[test] + fn test_movie_overview_end_no_op_when_not_ready() { + let mut app = app_with_movie_overview(); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_navigation_popped}; + + #[test] + fn test_movie_overview_esc_tears_down_the_movie_overview_modal() { + let mut app = app_with_movie_overview(); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveRadarrBlock::CollectionDetails.into()); + assert_modal_absent!(app.data.radarr_data.movie_overview_modal); + } + + #[test] + fn test_movie_overview_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_movie_overview(); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveRadarrBlock::CollectionDetails.into()); + assert_modal_absent!(app.data.radarr_data.movie_overview_modal); + } + } + + #[test] + fn test_scrolling_the_overview_does_not_mutate_the_collection_movie_record() { + let mut app = app_with_movie_overview(); + let overview_before = app + .data + .radarr_data + .collection_movies + .current_selection() + .overview + .clone(); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + assert_eq!( + app + .data + .radarr_data + .collection_movies + .current_selection() + .overview, + overview_before + ); + } + + #[test] + fn test_movie_overview_handler_accepts() { + ActiveRadarrBlock::iter().for_each(|active_radarr_block| { + if active_radarr_block == ActiveRadarrBlock::ViewMovieOverview { + assert!( + MovieOverviewHandler::accepts(active_radarr_block), + "{active_radarr_block} is not accepted by the MovieOverviewHandler" + ); + } else { + assert!( + !MovieOverviewHandler::accepts(active_radarr_block), + "{active_radarr_block} is wrongly accepted by the MovieOverviewHandler" + ); + } + }); + } + + #[test] + fn test_movie_overview_handler_dispatches_no_network_events() { + let mut app = app_with_movie_overview(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + MovieOverviewHandler::new(key, &mut app, ActiveRadarrBlock::ViewMovieOverview, None).handle(); + + assert_none!(app.data.radarr_data.prompt_confirm_action); + assert!(!app.data.radarr_data.prompt_confirm); + } + } + + #[test] + fn test_movie_overview_handler_is_not_ready_when_loading() { + let mut app = app_with_movie_overview(); + app.is_loading = true; + + let handler = MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_movie_overview_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = app_with_movie_overview(); + app.data.radarr_data.movie_overview_modal = None; + + let handler = MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_movie_overview_handler_is_ready() { + let mut app = app_with_movie_overview(); + + let handler = MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_movie_overview_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_movie_overview(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_movie_overview() -> App<'static> { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()); + app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + + app + } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .radarr_data + .movie_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .radarr_data + .movie_overview_modal + .as_mut() + .unwrap() + .overview + .offset = offset; + } +} diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index cc54e07c..92c0f4a8 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -295,7 +295,12 @@ pub(in crate::handlers::radarr_handlers) mod utils { pub fn collection_movie() -> CollectionMovie { CollectionMovie { title: "Test".to_owned().into(), - overview: "Collection blah blah blah".to_owned(), + overview: "Collection movie blah blah blah\r\n\ + \r\n\ + It was shot in Madison, Wisconsin: a city the cast never really left.\r\n\ + \r\n\ + The sequel took seven years to finish.\r\n" + .to_owned(), year: 2023, runtime: 120, tmdb_id: 1234, diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 9dd2c599..b96cbfeb 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -247,3 +247,9 @@ impl From<&RadarrData<'_>> for EditCollectionModal { edit_collection_modal } } + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct MovieOverviewModal { + pub overview: ScrollableText, +} diff --git a/src/models/servarr_data/radarr/modals_tests.rs b/src/models/servarr_data/radarr/modals_tests.rs index dabac4da..f12c70d0 100644 --- a/src/models/servarr_data/radarr/modals_tests.rs +++ b/src/models/servarr_data/radarr/modals_tests.rs @@ -2,7 +2,7 @@ mod test { use crate::models::radarr_models::{Collection, MinimumAvailability, Movie, MovieMonitor}; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, + AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, MovieOverviewModal, }; use crate::models::servarr_data::radarr::radarr_data::RadarrData; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; @@ -254,4 +254,11 @@ mod test { assert_eq!(edit_collection_modal.monitored, Some(true)); assert_eq!(edit_collection_modal.search_on_add, Some(true)); } + + #[test] + fn test_movie_overview_modal_default() { + let movie_overview_modal = MovieOverviewModal::default(); + + assert_is_empty!(movie_overview_modal.overview); + } } diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index 22fc4a1d..3282f3e3 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -12,7 +12,7 @@ use crate::models::radarr_models::{ }; use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditMovieModal, MovieDetailsModal, + AddMovieModal, EditCollectionModal, EditMovieModal, MovieDetailsModal, MovieOverviewModal, }; use crate::models::servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}; use crate::models::stateful_list::StatefulList; @@ -86,6 +86,7 @@ pub struct RadarrData<'a> { pub indexer_test_errors: Option, pub indexer_test_all_results: Option>, pub movie_details_modal: Option, + pub movie_overview_modal: Option, pub prompt_confirm: bool, pub prompt_confirm_action: Option, pub delete_movie_files: bool, @@ -158,6 +159,7 @@ impl<'a> Default for RadarrData<'a> { indexer_test_errors: None, indexer_test_all_results: None, movie_details_modal: None, + movie_overview_modal: None, prompt_confirm: false, prompt_confirm_action: None, delete_movie_files: false, @@ -360,6 +362,10 @@ impl RadarrData<'_> { .movie_releases .sorting(vec![sort_option!(indexer_id)]); + let movie_overview_modal = MovieOverviewModal { + overview: ScrollableText::with_string(collection_movie().overview.clone()), + }; + let mut radarr_data = RadarrData { disk_space_vec: vec![diskspace()], version: "1.2.3.4".to_owned(), @@ -378,6 +384,7 @@ impl RadarrData<'_> { indexer_test_errors: Some("error".into()), indexer_test_all_results: Some(indexer_test_results), movie_details_modal: Some(movie_details_modal), + movie_overview_modal: Some(movie_overview_modal), delete_movie_files: true, ..RadarrData::default() }; diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index 15b25f0e..67145dee 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -135,6 +135,7 @@ mod tests { assert_none!(&radarr_data.indexer_test_errors); assert_none!(&radarr_data.indexer_test_all_results); assert_none!(&radarr_data.movie_details_modal); + assert_none!(&radarr_data.movie_overview_modal); assert_none!(&radarr_data.prompt_confirm_action); assert!(!radarr_data.prompt_confirm); assert!(!radarr_data.delete_movie_files); diff --git a/src/network/radarr_network/collections/radarr_collections_network_tests.rs b/src/network/radarr_network/collections/radarr_collections_network_tests.rs index 903cc514..e5b4a687 100644 --- a/src/network/radarr_network/collections/radarr_collections_network_tests.rs +++ b/src/network/radarr_network/collections/radarr_collections_network_tests.rs @@ -28,7 +28,7 @@ mod tests { "movies": [ { "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -109,7 +109,7 @@ mod tests { "movies": [ { "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -189,7 +189,7 @@ mod tests { "qualityProfileId": 2222, "movies": [{ "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -218,7 +218,7 @@ mod tests { "qualityProfileId": 2222, "movies": [{ "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -306,7 +306,7 @@ mod tests { "qualityProfileId": 2222, "movies": [{ "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -335,7 +335,7 @@ mod tests { "qualityProfileId": 2222, "movies": [{ "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, diff --git a/src/network/radarr_network/radarr_network_test_utils.rs b/src/network/radarr_network/radarr_network_test_utils.rs index b361f34f..8888dbdb 100644 --- a/src/network/radarr_network/radarr_network_test_utils.rs +++ b/src/network/radarr_network/radarr_network_test_utils.rs @@ -79,7 +79,7 @@ pub mod test_utils { "movies": [ { "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -177,7 +177,12 @@ pub mod test_utils { pub fn collection_movie() -> CollectionMovie { CollectionMovie { title: "Test".to_owned().into(), - overview: "Collection blah blah blah".to_owned(), + overview: "Collection movie blah blah blah\r\n\ + \r\n\ + It was shot in Madison, Wisconsin: a city the cast never really left.\r\n\ + \r\n\ + The sequel took seven years to finish.\r\n" + .to_owned(), year: 2023, runtime: 120, tmdb_id: 1234, diff --git a/src/ui/radarr_ui/collections/collection_details_ui.rs b/src/ui/radarr_ui/collections/collection_details_ui.rs index f0bf0d42..250b88ab 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui.rs @@ -1,7 +1,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::Stylize; -use ratatui::text::{Line, Text}; +use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use crate::app::App; @@ -10,11 +10,12 @@ use crate::models::radarr_models::CollectionMovie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; -use crate::ui::styles::ManagarrStyle; +use crate::ui::styles::{ManagarrStyle, default_style}; use crate::ui::utils::{ borderless_block, get_width_from_percentage, layout_block_top_border_with_title, title_block, title_style, }; +use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::Size; use crate::ui::{DrawUi, draw_popup}; @@ -219,20 +220,35 @@ fn draw_movie_overview(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .flex(Flex::SpaceBetween) .margin(1) .areas(area); - let overview = Text::from( - app - .data - .radarr_data - .collection_movies - .current_selection() - .clone() - .overview, - ) - .default_color(); - let paragraph = Paragraph::new(overview) - .block(borderless_block()) - .wrap(Wrap { trim: false }); + match app.data.radarr_data.movie_overview_modal.as_ref() { + Some(movie_overview_modal) if !app.is_loading => { + let overview = &movie_overview_modal.overview; + let text = Text::from( + overview + .items + .iter() + .map(|line| overview_line(line)) + .collect::>>(), + ); - f.render_widget(paragraph, paragraph_area); + let paragraph = Paragraph::new(text) + .block(borderless_block()) + .wrap(Wrap { trim: false }) + .scroll((overview.offset, 0)); + + f.render_widget(paragraph, paragraph_area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.radarr_data.movie_overview_modal.is_none(), + borderless_block(), + ), + paragraph_area, + ), + } +} + +fn overview_line(line: &str) -> Line<'static> { + Line::from(Span::styled(line.trim_end().to_owned(), default_style())) } 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 9a78c2fd..b727a4a2 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui_tests.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui_tests.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod tests { + use pretty_assertions::assert_str_eq; use strum::IntoEnumIterator; use crate::app::App; @@ -7,9 +8,39 @@ mod tests { ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; use crate::ui::DrawUi; - use crate::ui::radarr_ui::collections::collection_details_ui::CollectionDetailsUi; + use crate::ui::radarr_ui::collections::collection_details_ui::{ + CollectionDetailsUi, overview_line, + }; use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + #[test] + fn test_overview_line_trims_a_trailing_carriage_return() { + let line = overview_line("The sequel took seven years to finish.\r"); + + assert_str_eq!( + line.spans[0].content, + "The sequel took seven years to finish." + ); + } + + #[test] + fn test_overview_line_renders_a_carriage_return_only_line_as_blank() { + let line = overview_line("\r"); + + assert_str_eq!(line.spans[0].content, ""); + } + + #[test] + fn test_overview_line_does_not_split_a_label_from_a_colon_in_the_prose() { + let line = overview_line("It was shot in Madison, Wisconsin: a city the cast never left."); + + assert_eq!(line.spans.len(), 1); + assert_str_eq!( + line.spans[0].content, + "It was shot in Madison, Wisconsin: a city the cast never left." + ); + } + #[test] fn test_collection_details_ui_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { @@ -36,6 +67,32 @@ mod tests { )); } + #[test] + fn test_scrolling_the_movie_overview_does_not_scroll_the_collection_details_behind_it() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()); + + let before = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + app + .data + .radarr_data + .movie_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 3; + + let after = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + assert_contains!(before, "Overview: Collection blah blah blah"); + assert_str_eq!(before, after); + } + mod snapshot_tests { use super::*; use crate::models::stateful_table::StatefulTable; @@ -59,6 +116,52 @@ mod tests { insta::assert_snapshot!(active_radarr_block.to_string(), output); } + #[test] + fn test_collection_details_ui_renders_scrolled_movie_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + app + .data + .radarr_data + .movie_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_collection_details_ui_renders_movie_overview_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_collection_details_ui_renders_movie_overview_when_the_modal_is_absent() { + let mut app = App::test_default_fully_populated(); + app.data.radarr_data.movie_overview_modal = None; + app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_collection_details_ui_renders_collection_details_empty() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap index f1294684..25dfa99e 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap @@ -18,12 +18,12 @@ expression: output │ │ │ │ │ ╭ Overview ────────────────────────────────────────────────────╮ │ - │ Movies ───────────────────│Collection blah blah blah │───────────────────────────│ + │ Movies ───────────────────│Collection movie blah blah blah │───────────────────────────│ │ ✔ Title │ │ │ - │=> ✔ Test │ │family, fun │ - │ │ │ │ - │ │ │ │ + │=> ✔ Test │It was shot in Madison, Wisconsin: a city the cast never really │family, fun │ + │ │left. │ │ │ │ │ │ + │ │The sequel took seven years to finish. │ │ │ │ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap new file mode 100644 index 00000000..f2d2490d --- /dev/null +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/collections/collection_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Availability: Released │ + │Monitored: Yes │ + │Search on Add: Yes │ + │ │ + │ │ + │ ╭ Overview ────────────────────────────────────────────────────╮ │ + │ Movies ───────────────────│ │───────────────────────────│ + │ │ │ │ + │ │ Loading ... │ │ + │ Loading ... │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap new file mode 100644 index 00000000..474665f3 --- /dev/null +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/collections/collection_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Availability: Released │ + │Monitored: Yes │ + │Search on Add: Yes │ + │ │ + │ │ + │ ╭ Overview ────────────────────────────────────────────────────╮ │ + │ Movies ───────────────────│ │───────────────────────────│ + │ ✔ Title │ │ │ + │=> ✔ Test │ Loading ... │family, fun │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap new file mode 100644 index 00000000..b03f945a --- /dev/null +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/collections/collection_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Availability: Released │ + │Monitored: Yes │ + │Search on Add: Yes │ + │ │ + │ │ + │ ╭ Overview ────────────────────────────────────────────────────╮ │ + │ Movies ───────────────────│It was shot in Madison, Wisconsin: a city the cast never really │───────────────────────────│ + │ ✔ Title │left. │ │ + │=> ✔ Test │ │family, fun │ + │ │The sequel took seven years to finish. │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMoviePrompt_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMoviePrompt_CollectionDetails.snap index ae1d2432..5c152515 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMoviePrompt_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMoviePrompt_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ Root Folder: │/nfs ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMinimumAvailability_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMinimumAvailability_CollectionDetails.snap index dfb9281c..dbef8d7d 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMinimumAvailability_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMinimumAvailability_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ ╭───────────────────────────────╮ ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMonitor_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMonitor_CollectionDetails.snap index 50f15fed..309deaa2 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMonitor_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMonitor_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ ╭───────────────────────────────╮ ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectQualityProfile_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectQualityProfile_CollectionDetails.snap index 4c3ee663..e373d8f7 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectQualityProfile_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectQualityProfile_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ ╭───────────────────────────────╮ ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectRootFolder_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectRootFolder_CollectionDetails.snap index 23dfbaaf..91b19825 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectRootFolder_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectRootFolder_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ ╭───────────────────────────────╮ ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieTagsInput_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieTagsInput_CollectionDetails.snap index ae1d2432..5c152515 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieTagsInput_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieTagsInput_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ Root Folder: │/nfs ▼ │ │ │ From 4cbec79334e7d1207fd6465405d2403333470f21 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 10:41:28 -0600 Subject: [PATCH 108/170] test(radarr): pin table search, filter and selection behaviour Radarr handler tests never asserted which block the search and filter keys push, so transposing searching_block and filtering_block in a TableHandlingConfig went undetected. Add a search key and a filter key test for each of the three handlers that configure them: library, collections and history. No test moved a table or list selection off index 0 either, which made current_selection() and items[0] indistinguishable. Select a non-zero index in the tests covering every handler that reads a selected row, enriching fixtures with a second, distinct entry where only one existed. Several fixtures also asserted against a default value, so the expected result was indistinguishable from an unset field. The system details tasks now use two distinct non-default task names, and the add movie body expects a non-default monitor and minimum availability. --- .../blocklist/blocklist_handler_tests.rs | 9 +- .../collection_details_handler_tests.rs | 14 +-- .../collections/collections_handler_tests.rs | 42 +++++++++ .../edit_collection_handler_tests.rs | 87 ++++++++++++++----- .../downloads/downloads_handler_tests.rs | 48 ++++++---- .../history/history_handler_tests.rs | 34 ++++++++ .../indexers/edit_indexer_handler_tests.rs | 29 +++++-- .../indexers/indexers_handler_tests.rs | 32 +++++-- .../library/add_movie_handler_tests.rs | 68 ++++++++++++--- .../library/delete_movie_handler_tests.rs | 23 ++++- .../library/edit_movie_handler_tests.rs | 54 ++++++++---- .../library/library_handler_tests.rs | 59 ++++++++++++- .../library/movie_details_handler_tests.rs | 28 ++++-- .../radarr_handler_test_utils.rs | 4 +- .../root_folders_handler_tests.rs | 32 ++++--- .../system/system_details_handler_tests.rs | 51 +++++++---- 16 files changed, 481 insertions(+), 133 deletions(-) diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index bc53e171..316dfdba 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -154,7 +154,7 @@ mod tests { #[case( ActiveRadarrBlock::Blocklist, ActiveRadarrBlock::DeleteBlocklistItemPrompt, - RadarrEvent::DeleteBlocklistItem(3) + RadarrEvent::DeleteBlocklistItem(2) )] #[case( ActiveRadarrBlock::Blocklist, @@ -168,6 +168,7 @@ mod tests { ) { let mut app = App::test_default(); app.data.radarr_data.blocklist.set_items(blocklist_vec()); + app.data.radarr_data.blocklist.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -353,7 +354,7 @@ mod tests { #[case( ActiveRadarrBlock::Blocklist, ActiveRadarrBlock::DeleteBlocklistItemPrompt, - RadarrEvent::DeleteBlocklistItem(3) + RadarrEvent::DeleteBlocklistItem(2) )] #[case( ActiveRadarrBlock::Blocklist, @@ -367,6 +368,7 @@ mod tests { ) { let mut app = App::test_default(); app.data.radarr_data.blocklist.set_items(blocklist_vec()); + app.data.radarr_data.blocklist.select_index(Some(1)); app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -556,6 +558,7 @@ mod tests { fn test_extract_blocklist_item_id() { let mut app = App::test_default(); app.data.radarr_data.blocklist.set_items(blocklist_vec()); + app.data.radarr_data.blocklist.select_index(Some(1)); let blocklist_item_id = BlocklistHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -565,7 +568,7 @@ mod tests { ) .extract_blocklist_item_id(); - assert_eq!(blocklist_item_id, 3); + assert_eq!(blocklist_item_id, 2); } #[test] 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 57e8ee54..35f980f8 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs @@ -142,11 +142,15 @@ mod tests { #[test] fn test_collection_details_submit_movie_already_in_library() { let mut app = App::test_default(); - app - .data - .radarr_data - .collection_movies - .set_items(vec![collection_movie()]); + app.data.radarr_data.collection_movies.set_items(vec![ + CollectionMovie { + tmdb_id: 5678, + overview: "Some other overview".to_owned(), + ..CollectionMovie::default() + }, + collection_movie(), + ]); + app.data.radarr_data.collection_movies.select_index(Some(1)); app.data.radarr_data.movies.set_items(vec![Movie { tmdb_id: collection_movie().tmdb_id, ..Movie::default() diff --git a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs index 2b15773c..17873078 100644 --- a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs @@ -381,6 +381,48 @@ mod tests { assert!(!app.should_refresh); } + #[test] + fn test_search_collections_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); + app + .data + .radarr_data + .collections + .set_items(vec![Collection::default()]); + + CollectionsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveRadarrBlock::Collections, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::SearchCollection.into()); + } + + #[test] + fn test_filter_collections_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); + app + .data + .radarr_data + .collections + .set_items(vec![Collection::default()]); + + CollectionsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::Collections, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::FilterCollections.into()); + } + #[test] fn test_update_all_collections_prompt_confirm_confirm() { let mut app = App::test_default(); 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 ac355afe..f4000935 100644 --- a/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs @@ -532,19 +532,34 @@ mod tests { edit_collection_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_collection_modal + .quality_profile_list + .state + .select(Some(1)); + edit_collection_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.edit_collection_modal = Some(edit_collection_modal); - app.data.radarr_data.collections.set_items(vec![Collection { - monitored: false, - search_on_add: false, - ..collection() - }]); + app.data.radarr_data.collections.set_items(vec![ + Collection { + id: 999, + ..collection() + }, + Collection { + monitored: false, + search_on_add: false, + ..collection() + }, + ]); + app.data.radarr_data.collections.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_collection_params = EditCollectionParams { collection_id: 123, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), search_on_add: Some(false), }; @@ -945,19 +960,34 @@ mod tests { edit_collection_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_collection_modal + .quality_profile_list + .state + .select(Some(1)); + edit_collection_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.edit_collection_modal = Some(edit_collection_modal); - app.data.radarr_data.collections.set_items(vec![Collection { - monitored: false, - search_on_add: false, - ..collection() - }]); + app.data.radarr_data.collections.set_items(vec![ + Collection { + id: 999, + ..collection() + }, + Collection { + monitored: false, + search_on_add: false, + ..collection() + }, + ]); + app.data.radarr_data.collections.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_collection_params = EditCollectionParams { collection_id: 123, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), search_on_add: Some(false), }; @@ -1034,19 +1064,34 @@ mod tests { edit_collection_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_collection_modal + .quality_profile_list + .state + .select(Some(1)); + edit_collection_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.edit_collection_modal = Some(edit_collection_modal); - app.data.radarr_data.collections.set_items(vec![Collection { - monitored: false, - search_on_add: false, - ..collection() - }]); + app.data.radarr_data.collections.set_items(vec![ + Collection { + id: 999, + ..collection() + }, + Collection { + monitored: false, + search_on_add: false, + ..collection() + }, + ]); + app.data.radarr_data.collections.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_collection_params = EditCollectionParams { collection_id: 123, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), search_on_add: Some(false), }; diff --git a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs index afc98052..45ed2bd7 100644 --- a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs @@ -136,7 +136,7 @@ mod tests { #[case( ActiveRadarrBlock::Downloads, ActiveRadarrBlock::DeleteDownloadPrompt, - RadarrEvent::DeleteDownload(1) + RadarrEvent::DeleteDownload(2) )] #[case( ActiveRadarrBlock::Downloads, @@ -149,11 +149,15 @@ mod tests { #[case] expected_action: RadarrEvent, ) { let mut app = App::test_default(); - app - .data - .radarr_data - .downloads - .set_items(vec![download_record()]); + app.data.radarr_data.downloads.set_items(vec![ + download_record(), + DownloadRecord { + id: 2, + title: "Test Download Title 2".to_owned(), + ..download_record() + }, + ]); + app.data.radarr_data.downloads.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -332,7 +336,7 @@ mod tests { #[case( ActiveRadarrBlock::Downloads, ActiveRadarrBlock::DeleteDownloadPrompt, - RadarrEvent::DeleteDownload(1) + RadarrEvent::DeleteDownload(2) )] #[case( ActiveRadarrBlock::Downloads, @@ -345,11 +349,15 @@ mod tests { #[case] expected_action: RadarrEvent, ) { let mut app = App::test_default(); - app - .data - .radarr_data - .downloads - .set_items(vec![download_record()]); + app.data.radarr_data.downloads.set_items(vec![ + download_record(), + DownloadRecord { + id: 2, + title: "Test Download Title 2".to_owned(), + ..download_record() + }, + ]); + app.data.radarr_data.downloads.select_index(Some(1)); app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -403,11 +411,15 @@ mod tests { #[test] fn test_extract_download_id() { let mut app = App::test_default(); - app - .data - .radarr_data - .downloads - .set_items(vec![download_record()]); + app.data.radarr_data.downloads.set_items(vec![ + download_record(), + DownloadRecord { + id: 2, + title: "Test Download Title 2".to_owned(), + ..download_record() + }, + ]); + app.data.radarr_data.downloads.select_index(Some(1)); let download_id = DownloadsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -417,7 +429,7 @@ mod tests { ) .extract_download_id(); - assert_eq!(download_id, 1); + assert_eq!(download_id, 2); } #[test] diff --git a/src/handlers/radarr_handlers/history/history_handler_tests.rs b/src/handlers/radarr_handlers/history/history_handler_tests.rs index 4bd6e51c..f3eb025a 100644 --- a/src/handlers/radarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/radarr_handlers/history/history_handler_tests.rs @@ -199,6 +199,40 @@ mod tests { assert_eq!(app.get_current_route(), ActiveRadarrBlock::History.into()); assert!(!app.should_refresh); } + + #[test] + fn test_search_history_key() { + let mut app = App::test_default(); + app.data.radarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveRadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveRadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::SearchHistory.into()); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::test_default(); + app.data.radarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveRadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::FilterHistory.into()); + } } #[test] 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 adc84549..9a773066 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -11,7 +11,7 @@ mod tests { use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::indexer; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; - use crate::models::servarr_models::EditIndexerParams; + use crate::models::servarr_models::{EditIndexerParams, Indexer}; use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; @@ -916,9 +916,14 @@ mod tests { priority: 25, }; app.data.radarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, + indexer_id: 2, name: Some("Test Update".to_owned()), enable_rss: Some(false), enable_automatic_search: Some(false), @@ -1759,9 +1764,14 @@ mod tests { priority: 25, }; app.data.radarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, + indexer_id: 2, name: Some("Test Update".to_owned()), enable_rss: Some(false), enable_automatic_search: Some(false), @@ -1837,9 +1847,14 @@ mod tests { priority: 25, }; app.data.radarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, + indexer_id: 2, name: Some("Test Update".to_owned()), enable_rss: Some(false), enable_automatic_search: Some(false), diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index cc1885d2..88808b3c 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -143,6 +143,11 @@ mod tests { } else { "usenet".to_owned() }; + let other_protocol = if torrent_protocol { + "usenet".to_owned() + } else { + "torrent".to_owned() + }; let mut expected_edit_indexer_modal = EditIndexerModal { name: "Test".into(), enable_rss: Some(true), @@ -186,7 +191,14 @@ mod tests { fields: Some(fields), ..Indexer::default() }; - radarr_data.indexers.set_items(vec![indexer]); + radarr_data.indexers.set_items(vec![ + Indexer { + protocol: other_protocol, + ..Indexer::default() + }, + indexer, + ]); + radarr_data.indexers.select_index(Some(1)); app.data.radarr_data = radarr_data; IndexersHandler::new(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Indexers, None).handle(); @@ -233,7 +245,12 @@ mod tests { #[test] fn test_delete_indexer_prompt_confirm_submit() { let mut app = App::test_default(); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into()); @@ -249,7 +266,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::DeleteIndexer(1) + &RadarrEvent::DeleteIndexer(2) ); assert_navigation_popped!(app, ActiveRadarrBlock::Indexers.into()); } @@ -523,7 +540,12 @@ mod tests { #[test] fn test_delete_indexer_prompt_confirm() { let mut app = App::test_default(); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into()); @@ -538,7 +560,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::DeleteIndexer(1) + &RadarrEvent::DeleteIndexer(2) ); assert_navigation_popped!(app, ActiveRadarrBlock::Indexers.into()); } 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 b61d7ec8..62e6daf5 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -17,7 +17,9 @@ mod tests { use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::add_movie_body; use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::collection_movie; use crate::models::HorizontallyScrollableText; - use crate::models::radarr_models::{AddMovieSearchResult, MinimumAvailability, MovieMonitor}; + use crate::models::radarr_models::{ + AddMovieSearchResult, CollectionMovie, MinimumAvailability, MovieMonitor, + }; use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ADD_MOVIE_BLOCKS, ActiveRadarrBlock}; use crate::models::servarr_models::RootFolder; @@ -942,7 +944,14 @@ mod tests { fn test_add_movie_search_results_submit_movie_already_in_library() { let mut app = App::test_default(); let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(vec![AddMovieSearchResult::default()]); + add_searched_movies.set_items(vec![ + AddMovieSearchResult { + tmdb_id: 5678, + ..AddMovieSearchResult::default() + }, + AddMovieSearchResult::default(), + ]); + add_searched_movies.select_index(Some(1)); app.data.radarr_data.add_searched_movies = Some(add_searched_movies); app .data @@ -1026,16 +1035,22 @@ mod tests { add_movie_modal.root_folder_list.state.select(Some(1)); add_movie_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); add_movie_modal .monitor_list .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + add_movie_modal.quality_profile_list.state.select(Some(1)); + add_movie_modal.monitor_list.state.select(Some(1)); + add_movie_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.add_movie_modal = Some(add_movie_modal); app.data.radarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let context = if movie_details_context { app .data @@ -1461,16 +1476,22 @@ mod tests { add_movie_modal.root_folder_list.state.select(Some(1)); add_movie_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); add_movie_modal .monitor_list .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + add_movie_modal.quality_profile_list.state.select(Some(1)); + add_movie_modal.monitor_list.state.select(Some(1)); + add_movie_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.add_movie_modal = Some(add_movie_modal); app.data.radarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let context = if movie_details_context { app .data @@ -1572,25 +1593,44 @@ mod tests { add_movie_modal.root_folder_list.state.select(Some(1)); add_movie_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); add_movie_modal .monitor_list .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + add_movie_modal.quality_profile_list.state.select(Some(1)); + add_movie_modal.monitor_list.state.select(Some(1)); + add_movie_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.add_movie_modal = Some(add_movie_modal); - app.data.radarr_data.quality_profile_map = BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + app.data.radarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let context = if movie_details_context { - app - .data - .radarr_data - .collection_movies - .set_items(vec![collection_movie()]); + app.data.radarr_data.collection_movies.set_items(vec![ + CollectionMovie { + tmdb_id: 5678, + title: HorizontallyScrollableText::from("Some Other Movie"), + ..CollectionMovie::default() + }, + collection_movie(), + ]); + app.data.radarr_data.collection_movies.select_index(Some(1)); Some(ActiveRadarrBlock::CollectionDetails) } else { let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(vec![add_movie_search_result()]); + add_searched_movies.set_items(vec![ + AddMovieSearchResult { + tmdb_id: 5678, + title: HorizontallyScrollableText::from("Some Other Movie"), + ..AddMovieSearchResult::default() + }, + add_movie_search_result(), + ]); + add_searched_movies.select_index(Some(1)); app.data.radarr_data.add_searched_movies = Some(add_searched_movies); 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 ca73c5a8..1e97b1e3 100644 --- a/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs @@ -10,7 +10,7 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::handlers::radarr_handlers::library::delete_movie_handler::DeleteMovieHandler; use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::movie; - use crate::models::radarr_models::DeleteMovieParams; + use crate::models::radarr_models::{DeleteMovieParams, Movie}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DELETE_MOVIE_BLOCKS}; mod test_handle_scroll_up_and_down { @@ -131,7 +131,12 @@ mod tests { }; app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; app.data.radarr_data.delete_movie_files = true; app.data.radarr_data.add_list_exclusion = true; @@ -275,7 +280,12 @@ mod tests { }; app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); 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); @@ -338,7 +348,12 @@ mod tests { #[test] fn test_build_delete_movie_params() { let mut app = App::test_default(); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.delete_movie_files = true; app.data.radarr_data.add_list_exclusion = true; let expected_delete_movie_params = DeleteMovieParams { 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 03bfc52e..ad878f9a 100644 --- a/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs @@ -656,18 +656,24 @@ mod tests { edit_movie .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_movie.quality_profile_list.state.select(Some(1)); + edit_movie.minimum_availability_list.state.select(Some(1)); app.data.radarr_data.edit_movie_modal = Some(edit_movie); - app.data.radarr_data.movies.set_items(vec![Movie { - monitored: false, - ..movie() - }]); + app.data.radarr_data.movies.set_items(vec![ + Movie { id: 999, ..movie() }, + Movie { + monitored: false, + ..movie() + }, + ]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_movie_params = EditMovieParams { movie_id: 1, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".into()), ..EditMovieParams::default() @@ -1089,18 +1095,24 @@ mod tests { edit_movie .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_movie.quality_profile_list.state.select(Some(1)); + edit_movie.minimum_availability_list.state.select(Some(1)); app.data.radarr_data.edit_movie_modal = Some(edit_movie); - app.data.radarr_data.movies.set_items(vec![Movie { - monitored: false, - ..movie() - }]); + app.data.radarr_data.movies.set_items(vec![ + Movie { id: 999, ..movie() }, + Movie { + monitored: false, + ..movie() + }, + ]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_movie_params = EditMovieParams { movie_id: 1, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".into()), ..EditMovieParams::default() @@ -1177,18 +1189,24 @@ mod tests { edit_movie .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_movie.quality_profile_list.state.select(Some(1)); + edit_movie.minimum_availability_list.state.select(Some(1)); app.data.radarr_data.edit_movie_modal = Some(edit_movie); - app.data.radarr_data.movies.set_items(vec![Movie { - monitored: false, - ..movie() - }]); + app.data.radarr_data.movies.set_items(vec![ + Movie { id: 999, ..movie() }, + Movie { + monitored: false, + ..movie() + }, + ]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_movie_params = EditMovieParams { movie_id: 1, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".into()), ..EditMovieParams::default() diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index 5ac7b9a1..be1174b1 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -391,6 +391,12 @@ mod tests { fn test_toggle_monitoring_key() { let mut app = App::test_default(); app.data.radarr_data = create_test_radarr_data(); + app + .data + .radarr_data + .movies + .set_items(vec![movie(), Movie { id: 2, ..movie() }]); + app.data.radarr_data.movies.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.is_routing = false; @@ -407,7 +413,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::ToggleMovieMonitoring(0) + &RadarrEvent::ToggleMovieMonitoring(2) ); } @@ -519,6 +525,48 @@ mod tests { assert!(!app.should_refresh); } + #[test] + fn test_search_movies_key() { + let mut app = App::test_default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::SearchMovie.into()); + } + + #[test] + fn test_filter_movies_key() { + let mut app = App::test_default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::FilterMovies.into()); + } + #[test] fn test_update_all_movies_prompt_confirm() { let mut app = App::test_default(); @@ -621,7 +669,12 @@ mod tests { #[test] fn test_extract_movie_id() { let mut app = App::test_default(); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![movie(), Movie { id: 2, ..movie() }]); + app.data.radarr_data.movies.select_index(Some(1)); let movie_id = LibraryHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -631,7 +684,7 @@ mod tests { ) .extract_movie_id(); - assert_eq!(movie_id, 1); + assert_eq!(movie_id, 2); } #[test] 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 a5d8077a..250301fe 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -17,7 +17,7 @@ mod tests { MovieDetailsHandler, releases_sorting_options, }; use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::{movie, release}; - use crate::models::radarr_models::{Credit, MovieHistoryItem}; + use crate::models::radarr_models::{Credit, Movie, MovieHistoryItem}; use crate::models::radarr_models::{RadarrRelease, RadarrReleaseDownloadBody}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; @@ -819,11 +819,22 @@ mod tests { fn test_build_radarr_release_download_body() { let mut app = App::test_default(); let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_releases - .set_items(vec![release()]); + movie_details_modal.movie_releases.set_items(vec![ + RadarrRelease { + guid: "9999".to_owned(), + indexer_id: 99, + ..release() + }, + release(), + ]); + movie_details_modal.movie_releases.select_index(Some(1)); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); let expected_body = RadarrReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, @@ -844,7 +855,12 @@ mod tests { #[test] fn test_extract_movie_id() { let mut app = App::test_default(); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); let movie_id = MovieDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index 92c0f4a8..82241948 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -475,13 +475,13 @@ pub(in crate::handlers::radarr_handlers) mod utils { tmdb_id: 1234, title: "Test".to_owned(), root_folder_path: "/nfs2".to_owned(), - minimum_availability: MinimumAvailability::Announced, + minimum_availability: MinimumAvailability::InCinemas, monitored: true, quality_profile_id: 2222, tags: Vec::new(), tag_input_string: Some("usenet, testing".into()), add_options: AddMovieOptions { - monitor: MovieMonitor::MovieOnly, + monitor: MovieMonitor::MovieAndCollection, search_for_movie: true, }, } 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 9c42cb27..a6157b88 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 @@ -314,11 +314,15 @@ mod tests { #[test] fn test_delete_root_folder_prompt_confirm_submit() { let mut app = App::test_default(); - app - .data - .radarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.radarr_data.root_folders.set_items(vec![ + root_folder(), + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + ..root_folder() + }, + ]); + app.data.radarr_data.root_folders.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into()); @@ -334,7 +338,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::DeleteRootFolder(1) + &RadarrEvent::DeleteRootFolder(2) ); assert_navigation_popped!(app, ActiveRadarrBlock::RootFolders.into()); } @@ -583,11 +587,15 @@ mod tests { #[test] fn test_delete_root_folder_prompt_confirm() { let mut app = App::test_default(); - app - .data - .radarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.radarr_data.root_folders.set_items(vec![ + root_folder(), + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + ..root_folder() + }, + ]); + app.data.radarr_data.root_folders.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into()); @@ -602,7 +610,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::DeleteRootFolder(1) + &RadarrEvent::DeleteRootFolder(2) ); assert_navigation_popped!(app, ActiveRadarrBlock::RootFolders.into()); } 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 a2eed580..d058ada4 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs @@ -677,10 +677,17 @@ mod tests { let mut app = App::test_default(); app.data.radarr_data.updates = ScrollableText::with_string("Test".to_owned()); app.data.radarr_data.prompt_confirm = true; - app.data.radarr_data.tasks.set_items(vec![RadarrTask { - task_name: RadarrTaskName::default(), - ..RadarrTask::default() - }]); + app.data.radarr_data.tasks.set_items(vec![ + RadarrTask { + task_name: RadarrTaskName::Backup, + ..RadarrTask::default() + }, + RadarrTask { + task_name: RadarrTaskName::RefreshMovie, + ..RadarrTask::default() + }, + ]); + app.data.radarr_data.tasks.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into()); app.push_navigation_stack(ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into()); @@ -695,7 +702,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::StartTask(RadarrTaskName::default()) + &RadarrEvent::StartTask(RadarrTaskName::RefreshMovie) ); assert_navigation_popped!(app, ActiveRadarrBlock::SystemTasks.into()); } @@ -891,10 +898,17 @@ mod tests { fn test_system_tasks_start_task_prompt_confirm() { let mut app = App::test_default(); app.data.radarr_data.updates = ScrollableText::with_string("Test".to_owned()); - app.data.radarr_data.tasks.set_items(vec![RadarrTask { - task_name: RadarrTaskName::default(), - ..RadarrTask::default() - }]); + app.data.radarr_data.tasks.set_items(vec![ + RadarrTask { + task_name: RadarrTaskName::Backup, + ..RadarrTask::default() + }, + RadarrTask { + task_name: RadarrTaskName::RefreshMovie, + ..RadarrTask::default() + }, + ]); + app.data.radarr_data.tasks.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into()); app.push_navigation_stack(ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into()); @@ -909,7 +923,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::StartTask(RadarrTaskName::default()) + &RadarrEvent::StartTask(RadarrTaskName::RefreshMovie) ); assert_navigation_popped!(app, ActiveRadarrBlock::SystemTasks.into()); } @@ -948,10 +962,17 @@ mod tests { #[test] fn test_extract_task_name() { let mut app = App::test_default(); - app.data.radarr_data.tasks.set_items(vec![RadarrTask { - task_name: RadarrTaskName::default(), - ..RadarrTask::default() - }]); + app.data.radarr_data.tasks.set_items(vec![ + RadarrTask { + task_name: RadarrTaskName::Backup, + ..RadarrTask::default() + }, + RadarrTask { + task_name: RadarrTaskName::RefreshMovie, + ..RadarrTask::default() + }, + ]); + app.data.radarr_data.tasks.select_index(Some(1)); let task_name = SystemDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -961,7 +982,7 @@ mod tests { ) .extract_task_name(); - assert_eq!(task_name, RadarrTaskName::default()); + assert_eq!(task_name, RadarrTaskName::RefreshMovie); } #[test] From 18a50ab97c0fe47d28fd068aa6e26ae977637fbd Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 11:25:34 -0600 Subject: [PATCH 109/170] test(sonarr): pin table search, filter and selection behaviour Sonarr handler tests never asserted which block the search and filter keys push, so transposing searching_block and filtering_block in a TableHandlingConfig went undetected. Add a search key test for each of the six searchable tables and a filter key test for each of the four filterable ones, covering library, history, series details and season details. No test moved a table or list selection off index 0 either, which made current_selection() and items[0] indistinguishable. Select a non-zero index in the tests covering every handler that reads a selected row, adding a decoy entry at index 0 so existing expected values stay meaningful. The modal list selections in the add and edit series prompts are a separate gap that a table-only sweep misses, so those are covered too. Several fixtures also asserted against a default value, so the expected result was indistinguishable from an unset field. The system tasks now use two distinct non-default task names, the season monitoring and manual search expectations use distinct non-zero ids, and the add and edit series bodies expect a non-default series type and monitor with distinct quality and language profile ids so the two lookups cannot be transposed. --- .../blocklist/blocklist_handler_tests.rs | 9 +- .../downloads/downloads_handler_tests.rs | 39 +- .../history/history_handler_tests.rs | 34 ++ .../indexers/edit_indexer_handler_tests.rs | 29 +- .../indexers/indexers_handler_tests.rs | 41 +- .../library/add_series_handler_tests.rs | 88 +++-- .../library/delete_series_handler_tests.rs | 29 +- .../library/edit_series_handler_tests.rs | 84 +++-- .../library/episode_details_handler_tests.rs | 165 +++++++- .../library/library_handler_tests.rs | 61 ++- .../library/season_details_handler_tests.rs | 353 +++++++++++++++++- .../library/series_details_handler_tests.rs | 128 ++++++- .../root_folders_handler_tests.rs | 42 ++- .../system/system_details_handler_tests.rs | 38 +- 14 files changed, 994 insertions(+), 146 deletions(-) diff --git a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs index 06c6adaa..bb26530a 100644 --- a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs @@ -158,7 +158,7 @@ mod tests { #[case( ActiveSonarrBlock::Blocklist, ActiveSonarrBlock::DeleteBlocklistItemPrompt, - SonarrEvent::DeleteBlocklistItem(3) + SonarrEvent::DeleteBlocklistItem(2) )] #[case( ActiveSonarrBlock::Blocklist, @@ -172,6 +172,7 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.data.sonarr_data.blocklist.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -356,7 +357,7 @@ mod tests { #[case( ActiveSonarrBlock::Blocklist, ActiveSonarrBlock::DeleteBlocklistItemPrompt, - SonarrEvent::DeleteBlocklistItem(3) + SonarrEvent::DeleteBlocklistItem(2) )] #[case( ActiveSonarrBlock::Blocklist, @@ -370,6 +371,7 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.data.sonarr_data.blocklist.select_index(Some(1)); app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -543,6 +545,7 @@ mod tests { fn test_extract_blocklist_item_id() { let mut app = App::test_default(); app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.data.sonarr_data.blocklist.select_index(Some(1)); let blocklist_item_id = BlocklistHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -552,7 +555,7 @@ mod tests { ) .extract_blocklist_item_id(); - assert_eq!(blocklist_item_id, 3); + assert_eq!(blocklist_item_id, 2); } #[test] diff --git a/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs index 67923760..876fa64e 100644 --- a/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs @@ -153,11 +153,14 @@ mod tests { #[case] expected_action: SonarrEvent, ) { let mut app = App::test_default(); - app - .data - .sonarr_data - .downloads - .set_items(vec![download_record()]); + app.data.sonarr_data.downloads.set_items(vec![ + DownloadRecord { + id: 999, + ..download_record() + }, + download_record(), + ]); + app.data.sonarr_data.downloads.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -350,11 +353,14 @@ mod tests { #[case] expected_action: SonarrEvent, ) { let mut app = App::test_default(); - app - .data - .sonarr_data - .downloads - .set_items(vec![download_record()]); + app.data.sonarr_data.downloads.set_items(vec![ + DownloadRecord { + id: 999, + ..download_record() + }, + download_record(), + ]); + app.data.sonarr_data.downloads.select_index(Some(1)); app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -408,11 +414,14 @@ mod tests { #[test] fn test_extract_download_id() { let mut app = App::test_default(); - app - .data - .sonarr_data - .downloads - .set_items(vec![download_record()]); + app.data.sonarr_data.downloads.set_items(vec![ + DownloadRecord { + id: 999, + ..download_record() + }, + download_record(), + ]); + app.data.sonarr_data.downloads.select_index(Some(1)); let download_id = DownloadsHandler::new( DEFAULT_KEYBINDINGS.esc.key, diff --git a/src/handlers/sonarr_handlers/history/history_handler_tests.rs b/src/handlers/sonarr_handlers/history/history_handler_tests.rs index 4a34d17c..e0053e63 100644 --- a/src/handlers/sonarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/sonarr_handlers/history/history_handler_tests.rs @@ -199,6 +199,40 @@ mod tests { assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); assert!(!app.should_refresh); } + + #[test] + fn test_search_history_key() { + let mut app = App::test_default(); + app.data.sonarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchHistory.into()); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::test_default(); + app.data.sonarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::FilterHistory.into()); + } } #[test] diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs index 6ebe7fb2..104fb3d4 100644 --- a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -11,7 +11,7 @@ mod tests { use crate::handlers::sonarr_handlers::sonarr_handler_test_utils::utils::indexer; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; - use crate::models::servarr_models::EditIndexerParams; + use crate::models::servarr_models::{EditIndexerParams, Indexer}; use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; @@ -916,7 +916,14 @@ mod tests { priority: 0, }; app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { indexer_id: 1, name: Some("Test Update".to_owned()), @@ -1758,7 +1765,14 @@ mod tests { priority: 0, }; app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { indexer_id: 1, name: Some("Test Update".to_owned()), @@ -1836,7 +1850,14 @@ mod tests { priority: 0, }; app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { indexer_id: 1, name: Some("Test Update".to_owned()), diff --git a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs index 81357dde..90838c7a 100644 --- a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs @@ -148,6 +148,11 @@ mod tests { } else { "usenet".to_owned() }; + let decoy_protocol = if torrent_protocol { + "usenet".to_owned() + } else { + "torrent".to_owned() + }; let mut expected_edit_indexer_modal = EditIndexerModal { name: "Test".into(), enable_rss: Some(true), @@ -191,7 +196,14 @@ mod tests { fields: Some(fields), ..Indexer::default() }; - sonarr_data.indexers.set_items(vec![indexer]); + sonarr_data.indexers.set_items(vec![ + Indexer { + protocol: decoy_protocol, + ..Indexer::default() + }, + indexer, + ]); + sonarr_data.indexers.select_index(Some(1)); app.data.sonarr_data = sonarr_data; IndexersHandler::new(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Indexers, None).handle(); @@ -239,7 +251,14 @@ mod tests { #[test] fn test_delete_indexer_prompt_confirm_submit() { let mut app = App::test_default(); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteIndexerPrompt.into()); @@ -538,7 +557,14 @@ mod tests { #[test] fn test_delete_indexer_prompt_confirm() { let mut app = App::test_default(); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteIndexerPrompt.into()); @@ -649,7 +675,14 @@ mod tests { #[test] fn test_extract_indexer_id() { let mut app = App::test_default(); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); let indexer_id = IndexersHandler::new( DEFAULT_KEYBINDINGS.esc.key, 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 ee28d97e..06f8f1f9 100644 --- a/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs @@ -1144,22 +1144,35 @@ mod tests { add_series_modal.root_folder_list.state.select(Some(1)); add_series_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + add_series_modal.quality_profile_list.state.select(Some(1)); add_series_modal .language_profile_list - .set_items(vec!["English".to_owned()]); + .set_items(vec!["Any".to_owned(), "English".to_owned()]); + add_series_modal.language_profile_list.state.select(Some(1)); add_series_modal .monitor_list .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal.monitor_list.state.select(Some(1)); add_series_modal .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + add_series_modal.series_type_list.state.select(Some(1)); app.data.sonarr_data.add_series_modal = Some(add_series_modal); app.data.sonarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); - app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(vec![add_series_search_result()]); + add_searched_series.set_items(vec![ + AddSeriesSearchResult { + tvdb_id: 999, + title: HorizontallyScrollableText::from("Decoy"), + ..add_series_search_result() + }, + add_series_search_result(), + ]); + add_searched_series.select_index(Some(1)); app.data.sonarr_data.add_searched_series = Some(add_searched_series); let expected_add_series_body = AddSeriesBody { tvdb_id: 1234, @@ -1167,13 +1180,13 @@ mod tests { monitored: true, root_folder_path: "/nfs2".to_owned(), quality_profile_id: 2222, - language_profile_id: 2222, - series_type: SeriesType::Standard, + language_profile_id: 4444, + series_type: SeriesType::Daily, season_folder: true, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddSeriesOptions { - monitor: SeriesMonitor::All, + monitor: SeriesMonitor::Unknown, search_for_cutoff_unmet_episodes: true, search_for_missing_episodes: true, }, @@ -1622,22 +1635,35 @@ mod tests { add_series_modal.root_folder_list.state.select(Some(1)); add_series_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + add_series_modal.quality_profile_list.state.select(Some(1)); add_series_modal .language_profile_list - .set_items(vec!["English".to_owned()]); + .set_items(vec!["Any".to_owned(), "English".to_owned()]); + add_series_modal.language_profile_list.state.select(Some(1)); add_series_modal .monitor_list .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal.monitor_list.state.select(Some(1)); add_series_modal .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + add_series_modal.series_type_list.state.select(Some(1)); app.data.sonarr_data.add_series_modal = Some(add_series_modal); app.data.sonarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); - app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(vec![add_series_search_result()]); + add_searched_series.set_items(vec![ + AddSeriesSearchResult { + tvdb_id: 999, + title: HorizontallyScrollableText::from("Decoy"), + ..add_series_search_result() + }, + add_series_search_result(), + ]); + add_searched_series.select_index(Some(1)); app.data.sonarr_data.add_searched_series = Some(add_searched_series); let expected_add_series_body = AddSeriesBody { tvdb_id: 1234, @@ -1645,13 +1671,13 @@ mod tests { monitored: true, root_folder_path: "/nfs2".to_owned(), quality_profile_id: 2222, - language_profile_id: 2222, - series_type: SeriesType::Standard, + language_profile_id: 4444, + series_type: SeriesType::Daily, season_folder: true, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddSeriesOptions { - monitor: SeriesMonitor::All, + monitor: SeriesMonitor::Unknown, search_for_cutoff_unmet_episodes: true, search_for_missing_episodes: true, }, @@ -1753,21 +1779,35 @@ mod tests { add_series_modal.root_folder_list.state.select(Some(1)); add_series_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + add_series_modal.quality_profile_list.state.select(Some(1)); add_series_modal .language_profile_list - .set_items(vec!["English".to_owned()]); + .set_items(vec!["Any".to_owned(), "English".to_owned()]); + add_series_modal.language_profile_list.state.select(Some(1)); add_series_modal .monitor_list .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal.monitor_list.state.select(Some(1)); add_series_modal .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + add_series_modal.series_type_list.state.select(Some(1)); app.data.sonarr_data.add_series_modal = Some(add_series_modal); - app.data.sonarr_data.quality_profile_map = BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); - app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(vec![add_series_search_result()]); + add_searched_series.set_items(vec![ + AddSeriesSearchResult { + tvdb_id: 999, + title: HorizontallyScrollableText::from("Decoy"), + ..add_series_search_result() + }, + add_series_search_result(), + ]); + add_searched_series.select_index(Some(1)); app.data.sonarr_data.add_searched_series = Some(add_searched_series); let expected_add_series_body = AddSeriesBody { tvdb_id: 1234, @@ -1775,13 +1815,13 @@ mod tests { monitored: true, root_folder_path: "/nfs2".to_owned(), quality_profile_id: 2222, - language_profile_id: 2222, - series_type: SeriesType::Standard, + language_profile_id: 4444, + series_type: SeriesType::Daily, season_folder: true, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddSeriesOptions { - monitor: SeriesMonitor::All, + monitor: SeriesMonitor::Unknown, search_for_cutoff_unmet_episodes: true, search_for_missing_episodes: true, }, diff --git a/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs index 50a65206..cc8e2879 100644 --- a/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs @@ -11,7 +11,7 @@ mod tests { use crate::handlers::sonarr_handlers::library::delete_series_handler::DeleteSeriesHandler; use crate::handlers::sonarr_handlers::sonarr_handler_test_utils::utils::series; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}; - use crate::models::sonarr_models::DeleteSeriesParams; + use crate::models::sonarr_models::{DeleteSeriesParams, Series}; mod test_handle_scroll_up_and_down { use pretty_assertions::assert_eq; @@ -133,7 +133,14 @@ mod tests { 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.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); let expected_delete_series_params = DeleteSeriesParams { id: 1, delete_series_files: true, @@ -278,7 +285,14 @@ mod tests { 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.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); let expected_delete_series_params = DeleteSeriesParams { id: 1, delete_series_files: true, @@ -345,7 +359,14 @@ mod tests { #[test] fn test_build_delete_series_params() { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.data.sonarr_data.delete_series_files = true; app.data.sonarr_data.add_list_exclusion = true; let expected_delete_series_params = DeleteSeriesParams { 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 6eb48b70..0992f666 100644 --- a/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs @@ -785,29 +785,39 @@ mod tests { edit_series .quality_profile_list .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + edit_series.quality_profile_list.state.select(Some(1)); edit_series .language_profile_list .set_items(vec!["Any".to_owned(), "English".to_owned()]); + edit_series.language_profile_list.state.select(Some(1)); edit_series .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + edit_series.series_type_list.state.select(Some(1)); app.data.sonarr_data.edit_series_modal = Some(edit_series); - app.data.sonarr_data.series.set_items(vec![Series { - monitored: false, - season_folder: false, - ..series() - }]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + Series { + monitored: false, + season_folder: false, + ..series() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.data.sonarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); app.data.sonarr_data.language_profiles_map = - BiMap::from_iter([(1111, "Any".to_owned()), (2222, "English".to_owned())]); + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let expected_edit_series_params = EditSeriesParams { series_id: 1, monitored: Some(false), use_season_folders: Some(false), - series_type: Some(SeriesType::Standard), - quality_profile_id: Some(1111), - language_profile_id: Some(1111), + series_type: Some(SeriesType::Daily), + quality_profile_id: Some(2222), + language_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditSeriesParams::default() @@ -1298,29 +1308,39 @@ mod tests { edit_series .quality_profile_list .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + edit_series.quality_profile_list.state.select(Some(1)); edit_series .language_profile_list .set_items(vec!["Any".to_owned(), "English".to_owned()]); + edit_series.language_profile_list.state.select(Some(1)); edit_series .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + edit_series.series_type_list.state.select(Some(1)); app.data.sonarr_data.edit_series_modal = Some(edit_series); - app.data.sonarr_data.series.set_items(vec![Series { - monitored: false, - season_folder: false, - ..series() - }]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + Series { + monitored: false, + season_folder: false, + ..series() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.data.sonarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); app.data.sonarr_data.language_profiles_map = - BiMap::from_iter([(1111, "Any".to_owned()), (2222, "English".to_owned())]); + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let expected_edit_series_params = EditSeriesParams { series_id: 1, monitored: Some(false), use_season_folders: Some(false), - series_type: Some(SeriesType::Standard), - quality_profile_id: Some(1111), - language_profile_id: Some(1111), + series_type: Some(SeriesType::Daily), + quality_profile_id: Some(2222), + language_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditSeriesParams::default() @@ -1395,29 +1415,39 @@ mod tests { edit_series .quality_profile_list .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + edit_series.quality_profile_list.state.select(Some(1)); edit_series .language_profile_list .set_items(vec!["Any".to_owned(), "English".to_owned()]); + edit_series.language_profile_list.state.select(Some(1)); edit_series .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + edit_series.series_type_list.state.select(Some(1)); app.data.sonarr_data.edit_series_modal = Some(edit_series); - app.data.sonarr_data.series.set_items(vec![Series { - monitored: false, - season_folder: false, - ..series() - }]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + Series { + monitored: false, + season_folder: false, + ..series() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.data.sonarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); app.data.sonarr_data.language_profiles_map = - BiMap::from_iter([(1111, "Any".to_owned()), (2222, "English".to_owned())]); + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let expected_edit_series_params = EditSeriesParams { series_id: 1, monitored: Some(false), use_season_folders: Some(false), - series_type: Some(SeriesType::Standard), - quality_profile_id: Some(1111), - language_profile_id: Some(1111), + series_type: Some(SeriesType::Daily), + quality_profile_id: Some(2222), + language_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditSeriesParams::default() diff --git a/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs index 1de4098d..cd5c5920 100644 --- a/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs @@ -11,7 +11,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, }; - use crate::models::sonarr_models::SonarrReleaseDownloadBody; + use crate::models::sonarr_models::{Episode, SonarrRelease, SonarrReleaseDownloadBody}; use crate::models::stateful_table::StatefulTable; use pretty_assertions::assert_eq; use rstest::rstest; @@ -229,7 +229,21 @@ mod tests { .as_mut() .unwrap() .episodes - .set_items(vec![episode()]); + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(prompt_block.into()); @@ -248,6 +262,61 @@ mod tests { fn test_manual_episode_search_confirm_prompt_confirm_submit() { let mut app = App::test_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 + .set_items(vec![ + SonarrRelease { + guid: "decoy guid".to_owned(), + indexer_id: 999, + ..SonarrRelease::default() + }, + SonarrRelease { + guid: "test guid".to_owned(), + indexer_id: 2, + ..SonarrRelease::default() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases + .select_index(Some(1)); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + Episode { id: 5, ..episode() }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearch.into()); app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt.into()); @@ -265,9 +334,9 @@ mod tests { assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, &SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { - guid: String::new(), - indexer_id: 0, - episode_id: Some(0), + guid: "test guid".to_owned(), + indexer_id: 2, + episode_id: Some(5), ..SonarrReleaseDownloadBody::default() }) ); @@ -546,7 +615,21 @@ mod tests { .as_mut() .unwrap() .episodes - .set_items(vec![episode()]); + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(ActiveSonarrBlock::AutomaticallySearchEpisodePrompt.into()); @@ -571,6 +654,61 @@ mod tests { fn test_episode_details_manual_search_confirm_prompt_confirm_confirm_key() { let mut app = App::test_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 + .set_items(vec![ + SonarrRelease { + guid: "decoy guid".to_owned(), + indexer_id: 999, + ..SonarrRelease::default() + }, + SonarrRelease { + guid: "test guid".to_owned(), + indexer_id: 2, + ..SonarrRelease::default() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases + .select_index(Some(1)); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + Episode { id: 5, ..episode() }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearch.into()); app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt.into()); @@ -588,9 +726,9 @@ mod tests { assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, &SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { - guid: String::new(), - indexer_id: 0, - episode_id: Some(0), + guid: "test guid".to_owned(), + indexer_id: 2, + episode_id: Some(5), ..SonarrReleaseDownloadBody::default() }) ); @@ -631,7 +769,14 @@ mod tests { fn test_extract_episode_id() { let mut app = App::test_default(); let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.set_items(vec![episode()]); + season_details_modal.episodes.set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + season_details_modal.episodes.select_index(Some(1)); app.data.sonarr_data.season_details_modal = Some(season_details_modal); let episode_id = EpisodeDetailsHandler::new( diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 0a605790..62bb1f05 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -380,6 +380,14 @@ mod tests { fn test_toggle_monitoring_key() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.is_routing = false; @@ -396,7 +404,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::ToggleSeriesMonitoring(0) + &SonarrEvent::ToggleSeriesMonitoring(1) ); } @@ -508,6 +516,48 @@ mod tests { assert!(!app.should_refresh); } + #[test] + fn test_search_series_key() { + let mut app = App::test_default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchSeries.into()); + } + + #[test] + fn test_filter_series_key() { + let mut app = App::test_default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::FilterSeries.into()); + } + #[test] fn test_update_all_series_prompt_confirm() { let mut app = App::test_default(); @@ -663,7 +713,14 @@ mod tests { #[test] fn test_extract_series_id() { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); let series_id = LibraryHandler::new( DEFAULT_KEYBINDINGS.esc.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 da8d1594..864d817e 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs @@ -16,7 +16,9 @@ mod tests { ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, }; use crate::models::servarr_models::{Language, Quality, QualityWrapper}; - use crate::models::sonarr_models::{SonarrRelease, SonarrReleaseDownloadBody}; + use crate::models::sonarr_models::{ + Episode, Season, Series, SonarrRelease, SonarrReleaseDownloadBody, + }; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::Number; @@ -268,11 +270,11 @@ mod tests { #[rstest] #[case( ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, - SonarrEvent::TriggerAutomaticSeasonSearch(0, 0) + SonarrEvent::TriggerAutomaticSeasonSearch(7, 3) )] #[case( ActiveSonarrBlock::DeleteEpisodeFilePrompt, - SonarrEvent::DeleteEpisodeFile(0) + SonarrEvent::DeleteEpisodeFile(9) )] fn test_season_details_prompt_confirm_submit( #[case] prompt_block: ActiveSonarrBlock, @@ -282,6 +284,55 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + id: 999, + episode_file_id: 999, + ..episode() + }, + Episode { + id: 5, + episode_file_id: 9, + ..episode() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(prompt_block.into()); @@ -300,6 +351,55 @@ mod tests { fn test_season_details_manual_search_confirm_prompt_confirm_submit() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .set_items(vec![ + SonarrRelease { + guid: "decoy guid".to_owned(), + indexer_id: 999, + ..SonarrRelease::default() + }, + SonarrRelease { + guid: "test guid".to_owned(), + indexer_id: 2, + ..SonarrRelease::default() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .select_index(Some(1)); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); @@ -317,10 +417,10 @@ mod tests { assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, &SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { - guid: String::new(), - indexer_id: 0, - series_id: Some(0), - season_number: Some(0), + guid: "test guid".to_owned(), + indexer_id: 2, + series_id: Some(7), + season_number: Some(3), ..SonarrReleaseDownloadBody::default() }) ); @@ -539,7 +639,21 @@ mod tests { .as_mut() .unwrap() .episodes - .set_items(vec![episode()]); + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); app.is_routing = false; @@ -691,14 +805,65 @@ mod tests { assert!(!app.is_routing); } + #[test] + fn test_search_episodes_key() { + let mut app = App::test_default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchEpisodes.into()); + } + + #[test] + fn test_search_season_history_key() { + let mut app = App::test_default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeasonHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchSeasonHistory.into()); + } + + #[test] + fn test_filter_season_history_key() { + let mut app = App::test_default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::SeasonHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::FilterSeasonHistory.into()); + } + #[rstest] #[case( ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, - SonarrEvent::TriggerAutomaticSeasonSearch(0, 0) + SonarrEvent::TriggerAutomaticSeasonSearch(7, 3) )] #[case( ActiveSonarrBlock::DeleteEpisodeFilePrompt, - SonarrEvent::DeleteEpisodeFile(0) + SonarrEvent::DeleteEpisodeFile(9) )] fn test_season_details_prompt_confirm_confirm_key( #[case] prompt_block: ActiveSonarrBlock, @@ -708,6 +873,55 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + id: 999, + episode_file_id: 999, + ..episode() + }, + Episode { + id: 5, + episode_file_id: 9, + ..episode() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(prompt_block.into()); @@ -732,6 +946,55 @@ mod tests { fn test_season_details_manual_search_confirm_prompt_confirm_confirm_key() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .set_items(vec![ + SonarrRelease { + guid: "decoy guid".to_owned(), + indexer_id: 999, + ..SonarrRelease::default() + }, + SonarrRelease { + guid: "test guid".to_owned(), + indexer_id: 2, + ..SonarrRelease::default() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .select_index(Some(1)); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); @@ -749,10 +1012,10 @@ mod tests { assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, &SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { - guid: String::new(), - indexer_id: 0, - series_id: Some(0), - season_number: Some(0), + guid: "test guid".to_owned(), + indexer_id: 2, + series_id: Some(7), + season_number: Some(3), ..SonarrReleaseDownloadBody::default() }) ); @@ -793,6 +1056,31 @@ mod tests { fn test_extract_episode_file_id() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + episode_file_id: 999, + ..episode() + }, + Episode { + episode_file_id: 9, + ..episode() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); let episode_file_id = SeasonDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -802,7 +1090,7 @@ mod tests { ) .extract_episode_file_id(); - assert_eq!(episode_file_id, 0); + assert_eq!(episode_file_id, 9); } #[test] @@ -823,7 +1111,14 @@ mod tests { fn test_extract_episode_id() { let mut app = App::test_default(); let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.set_items(vec![episode()]); + season_details_modal.episodes.set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + season_details_modal.episodes.select_index(Some(1)); app.data.sonarr_data.season_details_modal = Some(season_details_modal); let episode_id = SeasonDetailsHandler::new( @@ -855,6 +1150,28 @@ mod tests { fn test_extract_series_id_season_number_tuple() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); let (series_id, season_number) = SeasonDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -864,8 +1181,8 @@ mod tests { ) .extract_series_id_season_number_tuple(); - assert_eq!(series_id, 0); - assert_eq!(season_number, 0); + assert_eq!(series_id, 7); + assert_eq!(season_number, 3); } #[test] 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 33d9c6f1..d744e065 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs @@ -11,8 +11,8 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, }; - use crate::models::sonarr_models::Season; use crate::models::sonarr_models::SonarrHistoryItem; + use crate::models::sonarr_models::{Season, Series}; use crate::models::stateful_table::StatefulTable; use crate::test_handler_delegation; use pretty_assertions::assert_eq; @@ -190,7 +190,14 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data.prompt_confirm = true; - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.push_navigation_stack(prompt_block.into()); @@ -516,6 +523,25 @@ mod tests { fn test_toggle_monitoring_key() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..season() + }, + Season { + season_number: 2, + ..season() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.is_routing = false; @@ -535,7 +561,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::ToggleSeasonMonitoring(0, 0) + &SonarrEvent::ToggleSeasonMonitoring(1, 2) ); } @@ -696,6 +722,61 @@ mod tests { assert!(!app.is_routing); } + #[test] + fn test_search_seasons_key() { + let mut app = App::test_default(); + app.data.sonarr_data.seasons.set_items(vec![season()]); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchSeason.into()); + } + + #[test] + fn test_search_series_history_key() { + let mut app = App::test_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::SeriesHistory.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchSeriesHistory.into()); + } + + #[test] + fn test_filter_series_history_key() { + let mut app = App::test_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::SeriesHistory.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::FilterSeriesHistory.into()); + } + #[rstest] #[case( ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, @@ -712,7 +793,14 @@ mod tests { active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(prompt_block.into()); @@ -778,8 +866,25 @@ mod tests { #[test] fn test_extract_series_id_season_number_tuple() { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); - app.data.sonarr_data.seasons.set_items(vec![season()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..season() + }, + Season { + season_number: 2, + ..season() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); let series_id_season_number_tuple = SeriesDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -789,13 +894,20 @@ mod tests { ) .extract_series_id_season_number_tuple(); - assert_eq!(series_id_season_number_tuple, (1, 1)); + assert_eq!(series_id_season_number_tuple, (1, 2)); } #[test] fn test_extract_series_id() { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); let series_id = SeriesDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, 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 4710f67b..04aa511e 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 @@ -323,11 +323,15 @@ mod tests { #[test] fn test_delete_root_folder_prompt_confirm_submit() { let mut app = App::test_default(); - app - .data - .sonarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.sonarr_data.root_folders.set_items(vec![ + RootFolder { + id: 999, + path: "/decoy".to_owned(), + ..root_folder() + }, + root_folder(), + ]); + app.data.sonarr_data.root_folders.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteRootFolderPrompt.into()); @@ -595,11 +599,15 @@ mod tests { #[test] fn test_delete_root_folder_prompt_confirm() { let mut app = App::test_default(); - app - .data - .sonarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.sonarr_data.root_folders.set_items(vec![ + RootFolder { + id: 999, + path: "/decoy".to_owned(), + ..root_folder() + }, + root_folder(), + ]); + app.data.sonarr_data.root_folders.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteRootFolderPrompt.into()); @@ -653,11 +661,15 @@ mod tests { #[test] fn test_extract_root_folder_id() { let mut app = App::test_default(); - app - .data - .sonarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.sonarr_data.root_folders.set_items(vec![ + RootFolder { + id: 999, + path: "/decoy".to_owned(), + ..root_folder() + }, + root_folder(), + ]); + app.data.sonarr_data.root_folders.select_index(Some(1)); let root_folder_id = RootFoldersHandler::new( DEFAULT_KEYBINDINGS.esc.key, 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 a7031dae..6874edec 100644 --- a/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs @@ -694,11 +694,17 @@ mod tests { let mut app = App::test_default(); app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); app.data.sonarr_data.prompt_confirm = true; - app - .data - .sonarr_data - .tasks - .set_items(vec![SonarrTask::default()]); + app.data.sonarr_data.tasks.set_items(vec![ + SonarrTask { + task_name: SonarrTaskName::Backup, + ..SonarrTask::default() + }, + SonarrTask { + task_name: SonarrTaskName::RefreshMonitoredDownloads, + ..SonarrTask::default() + }, + ]); + app.data.sonarr_data.tasks.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); app.push_navigation_stack(ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into()); @@ -713,7 +719,7 @@ mod tests { assert!(app.data.sonarr_data.prompt_confirm); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::StartTask(SonarrTaskName::default()) + &SonarrEvent::StartTask(SonarrTaskName::RefreshMonitoredDownloads) ); assert_navigation_popped!(app, ActiveSonarrBlock::SystemTasks.into()); } @@ -912,11 +918,17 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveSonarrBlock::System.into()); app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); - app - .data - .sonarr_data - .tasks - .set_items(vec![SonarrTask::default()]); + app.data.sonarr_data.tasks.set_items(vec![ + SonarrTask { + task_name: SonarrTaskName::Backup, + ..SonarrTask::default() + }, + SonarrTask { + task_name: SonarrTaskName::RefreshMonitoredDownloads, + ..SonarrTask::default() + }, + ]); + app.data.sonarr_data.tasks.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); app.push_navigation_stack(ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into()); @@ -931,7 +943,9 @@ mod tests { assert!(app.data.sonarr_data.prompt_confirm); assert_eq!( app.data.sonarr_data.prompt_confirm_action, - Some(SonarrEvent::StartTask(SonarrTaskName::default())) + Some(SonarrEvent::StartTask( + SonarrTaskName::RefreshMonitoredDownloads + )) ); assert_navigation_popped!(app, ActiveSonarrBlock::SystemTasks.into()); } From f141e53f30025daccaa6ae9e14d04819f6c5f25d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 15:51:10 -0600 Subject: [PATCH 110/170] test(lidarr): pin table search, filter and selection behaviour The search and filter blocks a table configures were asserted nowhere, so transposing them was invisible. Selections were also left at index 0 in every test, which made current_selection() and items[0] indistinguishable, including for the profile lists inside the edit and add prompts. --- .../history/history_handler_tests.rs | 34 ++++ .../library/add_artist_handler_tests.rs | 145 ++++++++++++++---- .../library/album_details_handler_tests.rs | 48 ++++++ .../library/artist_details_handler_tests.rs | 65 +++++++- .../library/edit_artist_handler_tests.rs | 90 +++++++---- .../library/library_handler_tests.rs | 43 +++++- .../library/track_details_handler_tests.rs | 32 ++++ .../add_root_folder_handler_tests.rs | 42 +++-- 8 files changed, 419 insertions(+), 80 deletions(-) diff --git a/src/handlers/lidarr_handlers/history/history_handler_tests.rs b/src/handlers/lidarr_handlers/history/history_handler_tests.rs index 75c4e6fb..046b77b5 100644 --- a/src/handlers/lidarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/lidarr_handlers/history/history_handler_tests.rs @@ -196,6 +196,40 @@ mod tests { assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into()); assert!(!app.should_refresh); } + + #[test] + fn test_search_history_key() { + let mut app = App::test_default(); + app.data.lidarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchHistory.into()); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::test_default(); + app.data.lidarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterHistory.into()); + } } #[test] diff --git a/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs index 203089bb..308215b7 100644 --- a/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs @@ -1076,10 +1076,16 @@ mod tests { .set_index(0, ADD_ARTIST_SELECTION_BLOCKS.len() - 1); app.data.lidarr_data.prompt_confirm = true; let mut add_searched_artists = StatefulTable::default(); - add_searched_artists.set_items(vec![add_artist_search_result()]); + let mut decoy_search_result = add_artist_search_result(); + decoy_search_result.foreign_artist_id = "decoy-foreign-id".to_owned(); + decoy_search_result.artist_name = "Decoy Artist".into(); + add_searched_artists.set_items(vec![decoy_search_result, add_artist_search_result()]); + add_searched_artists.select_index(Some(1)); app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); - app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); - app.data.lidarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.lidarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "Lossless".to_owned())]); + app.data.lidarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); app.data.lidarr_data.add_artist_modal = Some(AddArtistModal { tags: "usenet, testing".into(), ..AddArtistModal::default() @@ -1092,6 +1098,15 @@ mod tests { .unwrap() .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .monitor_list + .state + .select(Some(1)); app .data .lidarr_data @@ -1100,6 +1115,15 @@ mod tests { .unwrap() .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .state + .select(Some(1)); app .data .lidarr_data @@ -1107,7 +1131,16 @@ mod tests { .as_mut() .unwrap() .quality_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["Any".to_owned(), "Lossless".to_owned()]); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .quality_profile_list + .state + .select(Some(1)); app .data .lidarr_data @@ -1115,7 +1148,16 @@ mod tests { .as_mut() .unwrap() .metadata_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .metadata_profile_list + .state + .select(Some(1)); app .data .lidarr_data @@ -1123,22 +1165,37 @@ mod tests { .as_mut() .unwrap() .root_folder_list - .set_items(vec![RootFolder { - path: "/music".to_owned(), - ..RootFolder::default() - }]); + .set_items(vec![ + RootFolder { + path: "/decoy".to_owned(), + ..RootFolder::default() + }, + RootFolder { + path: "/music".to_owned(), + ..RootFolder::default() + }, + ]); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .root_folder_list + .state + .select(Some(1)); let expected_add_artist_body = AddArtistBody { foreign_artist_id: "test-foreign-id".to_string(), artist_name: "Test Artist".to_string(), monitored: true, root_folder_path: "/music".to_string(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 2222, + metadata_profile_id: 4444, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddArtistOptions { - monitor: MonitorType::All, - monitor_new_items: NewItemMonitorType::All, + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, search_for_missing_albums: true, }, }; @@ -1468,10 +1525,16 @@ mod tests { .selected_block .set_index(0, ADD_ARTIST_SELECTION_BLOCKS.len() - 1); let mut add_searched_artists = StatefulTable::default(); - add_searched_artists.set_items(vec![add_artist_search_result()]); + let mut decoy_search_result = add_artist_search_result(); + decoy_search_result.foreign_artist_id = "decoy-foreign-id".to_owned(); + decoy_search_result.artist_name = "Decoy Artist".into(); + add_searched_artists.set_items(vec![decoy_search_result, add_artist_search_result()]); + add_searched_artists.select_index(Some(1)); app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); - app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); - app.data.lidarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.lidarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "Lossless".to_owned())]); + app.data.lidarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); let mut add_artist_modal = AddArtistModal { tags: "usenet, testing".into(), ..AddArtistModal::default() @@ -1479,15 +1542,22 @@ mod tests { add_artist_modal .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_artist_modal.monitor_list.state.select(Some(1)); add_artist_modal .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_artist_modal + .monitor_new_items_list + .state + .select(Some(1)); add_artist_modal .quality_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["Any".to_owned(), "Lossless".to_owned()]); + add_artist_modal.quality_profile_list.state.select(Some(1)); add_artist_modal .metadata_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + add_artist_modal.metadata_profile_list.state.select(Some(1)); add_artist_modal.root_folder_list.set_items(vec![ RootFolder { id: 1, @@ -1511,13 +1581,13 @@ mod tests { artist_name: "Test Artist".to_string(), monitored: true, root_folder_path: "/nfs2".to_string(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 2222, + metadata_profile_id: 4444, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddArtistOptions { - monitor: Default::default(), - monitor_new_items: Default::default(), + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, search_for_missing_albums: true, }, }; @@ -1641,34 +1711,47 @@ mod tests { add_artist_modal.root_folder_list.state.select(Some(1)); add_artist_modal .quality_profile_list - .set_items(vec!["Lossless".to_owned()]); + .set_items(vec!["Any".to_owned(), "Lossless".to_owned()]); + add_artist_modal.quality_profile_list.state.select(Some(1)); add_artist_modal .metadata_profile_list - .set_items(vec!["Standard".to_owned()]); + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + add_artist_modal.metadata_profile_list.state.select(Some(1)); add_artist_modal .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_artist_modal.monitor_list.state.select(Some(1)); add_artist_modal .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_artist_modal + .monitor_new_items_list + .state + .select(Some(1)); app.data.lidarr_data.add_artist_modal = Some(add_artist_modal); - app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1, "Lossless".to_owned())]); - app.data.lidarr_data.metadata_profile_map = BiMap::from_iter([(1, "Standard".to_owned())]); + app.data.lidarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "Lossless".to_owned())]); + app.data.lidarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); let mut add_searched_artists = StatefulTable::default(); - add_searched_artists.set_items(vec![add_artist_search_result()]); + let mut decoy_search_result = add_artist_search_result(); + decoy_search_result.foreign_artist_id = "decoy-foreign-id".to_owned(); + decoy_search_result.artist_name = "Decoy Artist".into(); + add_searched_artists.set_items(vec![decoy_search_result, add_artist_search_result()]); + add_searched_artists.select_index(Some(1)); app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); let expected_add_artist_body = AddArtistBody { foreign_artist_id: "test-foreign-id".to_string(), artist_name: "Test Artist".into(), monitored: true, root_folder_path: "/nfs2".to_string(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 2222, + metadata_profile_id: 4444, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddArtistOptions { - monitor: Default::default(), - monitor_new_items: Default::default(), + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, search_for_missing_albums: true, }, }; diff --git a/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs index f6335f09..eb3ad788 100644 --- a/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs @@ -660,6 +660,54 @@ mod tests { }) ); } + + #[test] + fn test_search_tracks_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumDetails.into()); + + AlbumDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::AlbumDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchTracks.into()); + } + + #[test] + fn test_search_album_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumHistory.into()); + + AlbumDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::AlbumHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchAlbumHistory.into()); + } + + #[test] + fn test_filter_album_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumHistory.into()); + + AlbumDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::AlbumHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterAlbumHistory.into()); + } } #[test] diff --git a/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs index 34eb1916..7670c63c 100644 --- a/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs @@ -452,7 +452,7 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler; use crate::handlers::lidarr_handlers::library::artist_overview_handler::ArtistOverviewHandler; - use crate::models::lidarr_models::Artist; + use crate::models::lidarr_models::{Album, Artist, LidarrHistoryItem}; use crate::models::servarr_data::lidarr::lidarr_data::{ ActiveLidarrBlock, EDIT_ARTIST_SELECTION_BLOCKS, }; @@ -966,6 +966,69 @@ mod tests { })) ); } + + #[test] + fn test_search_albums_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .albums + .set_items(vec![Album::default()]); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchAlbums.into()); + } + + #[test] + fn test_search_artist_history_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artist_history + .set_items(vec![LidarrHistoryItem::default()]); + app.push_navigation_stack(ActiveLidarrBlock::ArtistHistory.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::ArtistHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchArtistHistory.into()); + } + + #[test] + fn test_filter_artist_history_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artist_history + .set_items(vec![LidarrHistoryItem::default()]); + app.push_navigation_stack(ActiveLidarrBlock::ArtistHistory.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::ArtistHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterArtistHistory.into()); + } } #[test] diff --git a/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs index fee625cd..2075364d 100644 --- a/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs @@ -782,29 +782,41 @@ mod tests { edit_artist .quality_profile_list .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_artist.quality_profile_list.state.select(Some(1)); edit_artist .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_artist.metadata_profile_list.state.select(Some(1)); edit_artist .monitor_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_artist.monitor_list.state.select(Some(1)); app.data.lidarr_data.edit_artist_modal = Some(edit_artist); - app.data.lidarr_data.artists.set_items(vec![Artist { - monitored: false, - ..Artist::default() - }]); + app.data.lidarr_data.artists.set_items(vec![ + Artist { + id: 999, + monitored: false, + ..Artist::default() + }, + Artist { + id: 2, + monitored: false, + ..Artist::default() + }, + ]); + app.data.lidarr_data.artists.select_index(Some(1)); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([ (1111, "Lossless".to_owned()), (2222, "HD - 1080p".to_owned()), ]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_edit_artist_params = EditArtistParams { - artist_id: 0, + artist_id: 2, monitored: Some(false), - monitor_new_items: Some(NewItemMonitorType::All), - quality_profile_id: Some(1111), - metadata_profile_id: Some(1111), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(2222), + metadata_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditArtistParams::default() @@ -1236,29 +1248,41 @@ mod tests { edit_artist .quality_profile_list .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_artist.quality_profile_list.state.select(Some(1)); edit_artist .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_artist.metadata_profile_list.state.select(Some(1)); edit_artist .monitor_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_artist.monitor_list.state.select(Some(1)); app.data.lidarr_data.edit_artist_modal = Some(edit_artist); - app.data.lidarr_data.artists.set_items(vec![Artist { - monitored: false, - ..Artist::default() - }]); + app.data.lidarr_data.artists.set_items(vec![ + Artist { + id: 999, + monitored: false, + ..Artist::default() + }, + Artist { + id: 2, + monitored: false, + ..Artist::default() + }, + ]); + app.data.lidarr_data.artists.select_index(Some(1)); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([ (1111, "Lossless".to_owned()), (2222, "HD - 1080p".to_owned()), ]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_edit_artist_params = EditArtistParams { - artist_id: 0, + artist_id: 2, monitored: Some(false), - monitor_new_items: Some(NewItemMonitorType::All), - quality_profile_id: Some(1111), - metadata_profile_id: Some(1111), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(2222), + metadata_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditArtistParams::default() @@ -1332,29 +1356,41 @@ mod tests { edit_artist .quality_profile_list .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_artist.quality_profile_list.state.select(Some(1)); edit_artist .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_artist.metadata_profile_list.state.select(Some(1)); edit_artist .monitor_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_artist.monitor_list.state.select(Some(1)); app.data.lidarr_data.edit_artist_modal = Some(edit_artist); - app.data.lidarr_data.artists.set_items(vec![Artist { - monitored: false, - ..Artist::default() - }]); + app.data.lidarr_data.artists.set_items(vec![ + Artist { + id: 999, + monitored: false, + ..Artist::default() + }, + Artist { + id: 2, + monitored: false, + ..Artist::default() + }, + ]); + app.data.lidarr_data.artists.select_index(Some(1)); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([ (1111, "Lossless".to_owned()), (2222, "HD - 1080p".to_owned()), ]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_edit_artist_params = EditArtistParams { - artist_id: 0, + artist_id: 2, monitored: Some(false), - monitor_new_items: Some(NewItemMonitorType::All), - quality_profile_id: Some(1111), - metadata_profile_id: Some(1111), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(2222), + metadata_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditArtistParams::default() diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index 85ca1d34..0e4f93f6 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -244,11 +244,8 @@ mod tests { #[test] fn test_toggle_monitoring_key() { let mut app = App::test_default(); - app - .data - .lidarr_data - .artists - .set_items(vec![Artist::default()]); + app.data.lidarr_data.artists.set_items(artists_vec()); + app.data.lidarr_data.artists.select_index(Some(1)); app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.is_routing = false; @@ -265,7 +262,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.lidarr_data.prompt_confirm_action, - &LidarrEvent::ToggleArtistMonitoring(0) + &LidarrEvent::ToggleArtistMonitoring(2) ); } @@ -770,4 +767,38 @@ mod tests { assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); assert!(app.should_refresh); } + + #[test] + fn test_search_artists_key() { + let mut app = App::test_default(); + app.data.lidarr_data.artists.set_items(artists_vec()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchArtists.into()); + } + + #[test] + fn test_filter_artists_key() { + let mut app = App::test_default(); + app.data.lidarr_data.artists.set_items(artists_vec()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterArtists.into()); + } } diff --git a/src/handlers/lidarr_handlers/library/track_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/track_details_handler_tests.rs index 9529db72..55101bdf 100644 --- a/src/handlers/lidarr_handlers/library/track_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/track_details_handler_tests.rs @@ -244,6 +244,38 @@ mod tests { assert_eq!(app.get_current_route(), active_lidarr_block.into()); assert!(!app.is_routing); } + + #[test] + fn test_search_track_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::TrackHistory.into()); + + TrackDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::TrackHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchTrackHistory.into()); + } + + #[test] + fn test_filter_track_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::TrackHistory.into()); + + TrackDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::TrackHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterTrackHistory.into()); + } } #[test] diff --git a/src/handlers/lidarr_handlers/root_folders/add_root_folder_handler_tests.rs b/src/handlers/lidarr_handlers/root_folders/add_root_folder_handler_tests.rs index 004706d1..71376f98 100644 --- a/src/handlers/lidarr_handlers/root_folders/add_root_folder_handler_tests.rs +++ b/src/handlers/lidarr_handlers/root_folders/add_root_folder_handler_tests.rs @@ -1052,27 +1052,31 @@ mod tests { add_root_folder .quality_profile_list .set_items(vec!["Lossless".to_owned(), "FLAC".to_owned()]); + add_root_folder.quality_profile_list.state.select(Some(1)); add_root_folder .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + add_root_folder.metadata_profile_list.state.select(Some(1)); add_root_folder .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder.monitor_list.state.select(Some(1)); add_root_folder .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder.monitor_new_items_list.state.select(Some(1)); app.data.lidarr_data.add_root_folder_modal = Some(add_root_folder); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1111, "Lossless".to_owned()), (2222, "FLAC".to_owned())]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_add_root_folder_body = AddLidarrRootFolderBody { name: "Test Name".to_owned(), path: "/nfs/Test Path".to_owned(), - default_quality_profile_id: 1111, - default_metadata_profile_id: 1111, - default_monitor_option: MonitorType::All, - default_new_item_monitor_option: NewItemMonitorType::All, + default_quality_profile_id: 2222, + default_metadata_profile_id: 4444, + default_monitor_option: MonitorType::Future, + default_new_item_monitor_option: NewItemMonitorType::None, default_tags: Vec::new(), tag_input_string: Some("usenet, testing".to_owned()), }; @@ -1525,27 +1529,31 @@ mod tests { add_root_folder .quality_profile_list .set_items(vec!["Lossless".to_owned(), "FLAC".to_owned()]); + add_root_folder.quality_profile_list.state.select(Some(1)); add_root_folder .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + add_root_folder.metadata_profile_list.state.select(Some(1)); add_root_folder .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder.monitor_list.state.select(Some(1)); add_root_folder .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder.monitor_new_items_list.state.select(Some(1)); app.data.lidarr_data.add_root_folder_modal = Some(add_root_folder); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1111, "Lossless".to_owned()), (2222, "FLAC".to_owned())]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_add_root_folder_body = AddLidarrRootFolderBody { name: "Test Name".to_owned(), path: "/nfs/Test Path".to_owned(), - default_quality_profile_id: 1111, - default_metadata_profile_id: 1111, - default_monitor_option: MonitorType::All, - default_new_item_monitor_option: NewItemMonitorType::All, + default_quality_profile_id: 2222, + default_metadata_profile_id: 4444, + default_monitor_option: MonitorType::Future, + default_new_item_monitor_option: NewItemMonitorType::None, default_tags: Vec::new(), tag_input_string: Some("usenet, testing".to_owned()), }; @@ -1619,27 +1627,31 @@ mod tests { add_root_folder .quality_profile_list .set_items(vec!["Lossless".to_owned(), "FLAC".to_owned()]); + add_root_folder.quality_profile_list.state.select(Some(1)); add_root_folder .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + add_root_folder.metadata_profile_list.state.select(Some(1)); add_root_folder .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder.monitor_list.state.select(Some(1)); add_root_folder .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder.monitor_new_items_list.state.select(Some(1)); app.data.lidarr_data.add_root_folder_modal = Some(add_root_folder); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1111, "Lossless".to_owned()), (2222, "FLAC".to_owned())]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_add_root_folder_body = AddLidarrRootFolderBody { name: "Test Name".to_owned(), path: "/nfs/Test Path".to_owned(), - default_quality_profile_id: 1111, - default_metadata_profile_id: 1111, - default_monitor_option: MonitorType::All, - default_new_item_monitor_option: NewItemMonitorType::All, + default_quality_profile_id: 2222, + default_metadata_profile_id: 4444, + default_monitor_option: MonitorType::Future, + default_new_item_monitor_option: NewItemMonitorType::None, default_tags: Vec::new(), tag_input_string: Some("usenet, testing".to_owned()), }; From cef1ad13af94d3d3c1f05d6bd853bcb54a8e5ca8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 16:22:51 -0600 Subject: [PATCH 111/170] test(readarr): pin table search, filter and selection behaviour Readarr handler tests never asserted which block the search and filter keys push, so transposing searching_block and filtering_block in a TableHandlingConfig went undetected. History already had both tests; add the five missing search key tests and the three missing filter key tests, covering the authors, books, author history, editions and book history tables. The books and editions tables configure no filtering_block, so they get no filter test. No test moved a table or list selection off index 0 either, which made current_selection() and items[0] indistinguishable. Select a non-zero index in the tests covering every handler that reads a selected row, adding a decoy entry at index 0 so existing expected values stay meaningful. The profile, monitor and root folder lists inside the add author, edit author and add root folder prompts are a separate gap that a table-only sweep misses, so those are covered too, as are the book files, editions and releases tables held on the book details modal. Several fixtures also asserted against a default value, so the expected result was indistinguishable from an unset field. The add author, edit author and add root folder bodies now expect non-default monitor options, and the quality and metadata profile ids are drawn from disjoint id pairs so the two lookups cannot be transposed. --- .../library/add_author_handler_tests.rs | 169 ++++++++++++++---- .../library/author_details_handler_tests.rs | 97 +++++++++- .../library/book_details_handler_tests.rs | 128 ++++++++++--- .../library/delete_author_handler_tests.rs | 22 ++- .../library/delete_book_handler_tests.rs | 22 ++- .../library/edit_author_handler_tests.rs | 30 +++- .../library/library_handler_tests.rs | 43 ++++- .../add_root_folder_handler_tests.rs | 25 ++- 8 files changed, 447 insertions(+), 89 deletions(-) diff --git a/src/handlers/readarr_handlers/library/add_author_handler_tests.rs b/src/handlers/readarr_handlers/library/add_author_handler_tests.rs index e76ef7e9..562f3c5b 100644 --- a/src/handlers/readarr_handlers/library/add_author_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/add_author_handler_tests.rs @@ -947,10 +947,16 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); let mut add_searched_authors = StatefulTable::default(); - add_searched_authors.set_items(vec![add_author_search_result()]); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); app.data.readarr_data.add_searched_authors = Some(add_searched_authors); - app.data.readarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); - app.data.readarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "eBook".to_owned())]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); AddAuthorHandler::new( SUBMIT_KEY, @@ -1021,7 +1027,11 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); let mut add_searched_authors = StatefulTable::default(); - add_searched_authors.set_items(vec![add_author_search_result()]); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); app.data.readarr_data.add_searched_authors = Some(add_searched_authors); app .data @@ -1046,7 +1056,11 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); let mut add_searched_authors = StatefulTable::default(); - add_searched_authors.set_items(vec![add_author_search_result()]); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = OTHER_FOREIGN_AUTHOR_ID.to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); app.data.readarr_data.add_searched_authors = Some(add_searched_authors); app .data @@ -1107,10 +1121,16 @@ mod tests { .set_index(0, ADD_AUTHOR_SELECTION_BLOCKS.len() - 1); app.data.readarr_data.prompt_confirm = true; let mut add_searched_authors = StatefulTable::default(); - add_searched_authors.set_items(vec![add_author_search_result()]); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); app.data.readarr_data.add_searched_authors = Some(add_searched_authors); - app.data.readarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); - app.data.readarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "eBook".to_owned())]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); app.data.readarr_data.add_author_modal = Some(AddAuthorModal { tags: "usenet, testing".into(), ..AddAuthorModal::default() @@ -1123,6 +1143,15 @@ mod tests { .unwrap() .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .state + .select(Some(1)); app .data .readarr_data @@ -1131,6 +1160,15 @@ mod tests { .unwrap() .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .state + .select(Some(1)); app .data .readarr_data @@ -1138,7 +1176,16 @@ mod tests { .as_mut() .unwrap() .quality_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["Any".to_owned(), "eBook".to_owned()]); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .state + .select(Some(1)); app .data .readarr_data @@ -1146,7 +1193,16 @@ mod tests { .as_mut() .unwrap() .metadata_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .state + .select(Some(1)); app .data .readarr_data @@ -1154,22 +1210,37 @@ mod tests { .as_mut() .unwrap() .root_folder_list - .set_items(vec![RootFolder { - path: "/books".to_owned(), - ..RootFolder::default() - }]); + .set_items(vec![ + RootFolder { + path: "/decoy".to_owned(), + ..RootFolder::default() + }, + RootFolder { + path: "/books".to_owned(), + ..RootFolder::default() + }, + ]); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .state + .select(Some(1)); let expected_add_author_body = AddAuthorBody { foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_string(), author_name: "Test Author".to_string(), monitored: true, root_folder_path: "/books".to_string(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 2222, + metadata_profile_id: 4444, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddAuthorOptions { - monitor: MonitorType::All, - monitor_new_items: NewItemMonitorType::All, + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, search_for_missing_books: true, }, }; @@ -1498,10 +1569,16 @@ mod tests { .selected_block .set_index(0, ADD_AUTHOR_SELECTION_BLOCKS.len() - 1); let mut add_searched_authors = StatefulTable::default(); - add_searched_authors.set_items(vec![add_author_search_result()]); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); app.data.readarr_data.add_searched_authors = Some(add_searched_authors); - app.data.readarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); - app.data.readarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "eBook".to_owned())]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); let mut add_author_modal = AddAuthorModal { tags: "usenet, testing".into(), ..AddAuthorModal::default() @@ -1509,15 +1586,22 @@ mod tests { add_author_modal .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal.monitor_list.state.select(Some(1)); add_author_modal .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_author_modal + .monitor_new_items_list + .state + .select(Some(1)); add_author_modal .quality_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["Any".to_owned(), "eBook".to_owned()]); + add_author_modal.quality_profile_list.state.select(Some(1)); add_author_modal .metadata_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + add_author_modal.metadata_profile_list.state.select(Some(1)); add_author_modal.root_folder_list.set_items(vec![ RootFolder { id: 1, @@ -1541,13 +1625,13 @@ mod tests { author_name: "Test Author".to_string(), monitored: true, root_folder_path: "/nfs2".to_string(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 2222, + metadata_profile_id: 4444, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddAuthorOptions { - monitor: Default::default(), - monitor_new_items: Default::default(), + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, search_for_missing_books: true, }, }; @@ -1674,34 +1758,47 @@ mod tests { add_author_modal.root_folder_list.state.select(Some(1)); add_author_modal .quality_profile_list - .set_items(vec!["Any".to_owned()]); + .set_items(vec!["Any".to_owned(), "eBook".to_owned()]); + add_author_modal.quality_profile_list.state.select(Some(1)); add_author_modal .metadata_profile_list - .set_items(vec!["Standard".to_owned()]); + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + add_author_modal.metadata_profile_list.state.select(Some(1)); add_author_modal .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal.monitor_list.state.select(Some(1)); add_author_modal .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_author_modal + .monitor_new_items_list + .state + .select(Some(1)); app.data.readarr_data.add_author_modal = Some(add_author_modal); - app.data.readarr_data.quality_profile_map = BiMap::from_iter([(1, "Any".to_owned())]); - app.data.readarr_data.metadata_profile_map = BiMap::from_iter([(1, "Standard".to_owned())]); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "eBook".to_owned())]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); let mut add_searched_authors = StatefulTable::default(); - add_searched_authors.set_items(vec![add_author_search_result()]); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); app.data.readarr_data.add_searched_authors = Some(add_searched_authors); let expected_add_author_body = AddAuthorBody { foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_string(), author_name: "Test Author".into(), monitored: true, root_folder_path: "/nfs2".to_string(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 2222, + metadata_profile_id: 4444, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddAuthorOptions { - monitor: Default::default(), - monitor_new_items: Default::default(), + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, search_for_missing_books: true, }, }; diff --git a/src/handlers/readarr_handlers/library/author_details_handler_tests.rs b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs index 180aab68..034da500 100644 --- a/src/handlers/readarr_handlers/library/author_details_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs @@ -301,11 +301,15 @@ mod tests { fn test_manual_author_search_confirm_prompt_confirm_submit() { let mut app = App::test_default(); let release = torrent_release(); + let mut decoy_release = torrent_release(); + decoy_release.guid = "decoy-guid".to_owned(); + decoy_release.indexer_id = 999; app .data .readarr_data .author_releases - .set_items(vec![release.clone()]); + .set_items(vec![decoy_release, release.clone()]); + app.data.readarr_data.author_releases.select_index(Some(1)); app.data.readarr_data.prompt_confirm = true; let expected_action = ReadarrEvent::DownloadRelease(ReleaseDownloadBody { guid: release.guid, @@ -562,6 +566,16 @@ mod tests { fn test_author_details_view_key_builds_the_overview_from_the_selected_author() { let mut app = App::test_default_fully_populated(); app.data.readarr_data.author_overview_modal = None; + let author = app.data.readarr_data.authors.current_selection().clone(); + let mut decoy_author = author.clone(); + decoy_author.id = 999; + decoy_author.overview = Some("decoy author overview".to_owned()); + app + .data + .readarr_data + .authors + .set_items(vec![decoy_author, author]); + app.data.readarr_data.authors.select_index(Some(1)); app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); AuthorDetailsHandler::new( @@ -698,6 +712,15 @@ mod tests { #[test] fn test_author_details_toggle_monitoring_key() { let mut app = App::test_default_fully_populated(); + let book = app.data.readarr_data.books.current_selection().clone(); + let mut decoy_book = book.clone(); + decoy_book.id = 999; + app + .data + .readarr_data + .books + .set_items(vec![decoy_book, book]); + app.data.readarr_data.books.select_index(Some(1)); app.is_routing = false; app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); @@ -954,11 +977,15 @@ mod tests { fn test_manual_author_search_confirm_prompt_confirm_key() { let mut app = App::test_default(); let release = torrent_release(); + let mut decoy_release = torrent_release(); + decoy_release.guid = "decoy-guid".to_owned(); + decoy_release.indexer_id = 999; app .data .readarr_data .author_releases - .set_items(vec![release.clone()]); + .set_items(vec![decoy_release, release.clone()]); + app.data.readarr_data.author_releases.select_index(Some(1)); let expected_action = ReadarrEvent::DownloadRelease(ReleaseDownloadBody { guid: release.guid, indexer_id: release.indexer_id, @@ -981,6 +1008,54 @@ mod tests { &expected_action ); } + + #[test] + fn test_search_books_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchBooks.into()); + } + + #[test] + fn test_search_author_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchAuthorHistory.into()); + } + + #[test] + fn test_filter_author_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::FilterAuthorHistory.into()); + } } #[test] @@ -1012,6 +1087,15 @@ mod tests { #[test] fn test_extract_author_id() { let mut app = App::test_default_fully_populated(); + let author = app.data.readarr_data.authors.current_selection().clone(); + let mut decoy_author = author.clone(); + decoy_author.id = 999; + app + .data + .readarr_data + .authors + .set_items(vec![decoy_author, author]); + app.data.readarr_data.authors.select_index(Some(1)); let author_id = AuthorDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -1027,6 +1111,15 @@ mod tests { #[test] fn test_extract_book_id() { let mut app = App::test_default_fully_populated(); + let book = app.data.readarr_data.books.current_selection().clone(); + let mut decoy_book = book.clone(); + decoy_book.id = 999; + app + .data + .readarr_data + .books + .set_items(vec![decoy_book, book]); + app.data.readarr_data.books.select_index(Some(1)); let book_id = AuthorDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, diff --git a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs index e092410c..cfdf6808 100644 --- a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs @@ -494,7 +494,9 @@ mod tests { fn test_manual_book_search_confirm_prompt_submit() { let mut app = App::test_default(); app.data.readarr_data.prompt_confirm = true; - app.data.readarr_data.book_details_modal = Some(book_details_modal()); + let mut modal = book_details_modal(); + modal.book_releases.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(modal); app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); @@ -509,8 +511,8 @@ mod tests { assert_eq!( app.data.readarr_data.prompt_confirm_action, Some(ReadarrEvent::DownloadRelease(ReleaseDownloadBody { - guid: "guid-1".to_owned(), - indexer_id: 1, + guid: "guid-2".to_owned(), + indexer_id: 2, })) ); assert_navigation_pushed!(app, ActiveReadarrBlock::ManualBookSearch.into()); @@ -540,6 +542,7 @@ mod tests { let mut app = App::test_default(); app.data.readarr_data.prompt_confirm = true; app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); app.data.readarr_data.book_details_modal = Some(book_details_modal()); app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); @@ -583,7 +586,9 @@ mod tests { fn test_delete_book_file_prompt_submit() { let mut app = App::test_default(); app.data.readarr_data.prompt_confirm = true; - app.data.readarr_data.book_details_modal = Some(book_details_modal()); + let mut modal = book_details_modal(); + modal.book_files.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(modal); app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); @@ -810,6 +815,7 @@ mod tests { fn test_automatically_search_book_prompt_confirm() { let mut app = App::test_default(); app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); app.data.readarr_data.book_details_modal = Some(book_details_modal()); app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); @@ -833,7 +839,9 @@ mod tests { #[test] fn test_delete_book_file_prompt_confirm() { let mut app = App::test_default(); - app.data.readarr_data.book_details_modal = Some(book_details_modal()); + let mut modal = book_details_modal(); + modal.book_files.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(modal); app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); @@ -856,7 +864,9 @@ mod tests { #[test] fn test_manual_book_search_confirm_prompt_confirm() { let mut app = App::test_default(); - app.data.readarr_data.book_details_modal = Some(book_details_modal()); + let mut modal = book_details_modal(); + modal.book_releases.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(modal); app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); @@ -872,12 +882,63 @@ mod tests { assert_eq!( app.data.readarr_data.prompt_confirm_action, Some(ReadarrEvent::DownloadRelease(ReleaseDownloadBody { - guid: "guid-1".to_owned(), - indexer_id: 1, + guid: "guid-2".to_owned(), + indexer_id: 2, })) ); assert_navigation_popped!(app, ActiveReadarrBlock::ManualBookSearch.into()); } + + #[test] + fn test_search_editions_key() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchEditions.into()); + } + + #[test] + fn test_search_book_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::BookHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchBookHistory.into()); + } + + #[test] + fn test_filter_book_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveReadarrBlock::BookHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::FilterBookHistory.into()); + } } #[test] @@ -1057,26 +1118,47 @@ mod tests { } fn book_files_vec() -> Vec { - vec![BookFile { - id: 11, - author_id: 1, - book_id: 1, - path: "/nfs/books/Test Author/Test Book.epub".to_owned(), - size: 1024, - quality: QualityWrapper { - quality: Quality { - name: "EPUB".to_owned(), + vec![ + BookFile { + id: 999, + author_id: 1, + book_id: 1, + path: "/nfs/books/Test Author/Decoy Book.epub".to_owned(), + size: 2048, + quality: QualityWrapper { + quality: Quality { + name: "MOBI".to_owned(), + }, }, + ..BookFile::default() }, - ..BookFile::default() - }] + BookFile { + id: 11, + author_id: 1, + book_id: 1, + path: "/nfs/books/Test Author/Test Book.epub".to_owned(), + size: 1024, + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + ..BookFile::default() + }, + ] } fn books_vec() -> Vec { - vec![Book { - id: 7, - ..Book::default() - }] + vec![ + Book { + id: 999, + ..Book::default() + }, + Book { + id: 7, + ..Book::default() + }, + ] } fn history_vec() -> Vec { diff --git a/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs b/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs index ef7d7c7a..fcd4eef0 100644 --- a/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs @@ -138,7 +138,8 @@ mod tests { app.data.readarr_data.prompt_confirm = true; app.data.readarr_data.delete_files = false; app.data.readarr_data.add_import_list_exclusion = true; - app.data.readarr_data.authors.set_items(vec![author()]); + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); let expected_delete_author_params = DeleteParams { id: 42, delete_files: false, @@ -321,7 +322,8 @@ mod tests { app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); app.data.readarr_data.delete_files = true; app.data.readarr_data.add_import_list_exclusion = false; - app.data.readarr_data.authors.set_items(vec![author()]); + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); let expected_delete_author_params = DeleteParams { id: 42, delete_files: true, @@ -362,7 +364,8 @@ mod tests { app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); app.data.readarr_data.delete_files = true; app.data.readarr_data.add_import_list_exclusion = false; - app.data.readarr_data.authors.set_items(vec![author()]); + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); app @@ -423,7 +426,8 @@ mod tests { #[test] fn test_build_delete_author_params() { let mut app = App::test_default(); - app.data.readarr_data.authors.set_items(vec![author()]); + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); app.data.readarr_data.delete_files = true; app.data.readarr_data.add_import_list_exclusion = false; let expected_delete_author_params = DeleteParams { @@ -481,4 +485,14 @@ mod tests { ..Author::default() } } + + fn authors_vec() -> Vec { + vec![ + Author { + id: 999, + ..Author::default() + }, + author(), + ] + } } diff --git a/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs b/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs index 064e2dee..a6b8113b 100644 --- a/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs @@ -129,7 +129,8 @@ mod tests { app.data.readarr_data.prompt_confirm = true; app.data.readarr_data.delete_files = false; app.data.readarr_data.add_import_list_exclusion = true; - app.data.readarr_data.books.set_items(vec![book()]); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); let expected_delete_book_params = DeleteParams { id: 42, delete_files: false, @@ -309,7 +310,8 @@ mod tests { app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); app.data.readarr_data.delete_files = true; app.data.readarr_data.add_import_list_exclusion = false; - app.data.readarr_data.books.set_items(vec![book()]); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); let expected_delete_book_params = DeleteParams { id: 42, delete_files: true, @@ -349,7 +351,8 @@ mod tests { app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); app.data.readarr_data.delete_files = true; app.data.readarr_data.add_import_list_exclusion = false; - app.data.readarr_data.books.set_items(vec![book()]); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); app .data @@ -409,7 +412,8 @@ mod tests { #[test] fn test_build_delete_book_params() { let mut app = App::test_default(); - app.data.readarr_data.books.set_items(vec![book()]); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); app.data.readarr_data.delete_files = true; app.data.readarr_data.add_import_list_exclusion = false; let expected_delete_book_params = DeleteParams { @@ -467,4 +471,14 @@ mod tests { ..Book::default() } } + + fn books_vec() -> Vec { + vec![ + Book { + id: 999, + ..Book::default() + }, + book(), + ] + } } diff --git a/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs b/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs index e22b0bd9..6078c466 100644 --- a/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs @@ -1323,18 +1323,32 @@ mod tests { edit_author_modal .quality_profile_list .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_author_modal.quality_profile_list.state.select(Some(1)); edit_author_modal .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_author_modal + .metadata_profile_list + .state + .select(Some(1)); edit_author_modal .monitor_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_author_modal.monitor_list.state.select(Some(1)); app.data.readarr_data.edit_author_modal = Some(edit_author_modal); - app.data.readarr_data.authors.set_items(vec![Author { - id: 42, - monitored: false, - ..Author::default() - }]); + app.data.readarr_data.authors.set_items(vec![ + Author { + id: 999, + monitored: false, + ..Author::default() + }, + Author { + id: 42, + monitored: false, + ..Author::default() + }, + ]); + app.data.readarr_data.authors.select_index(Some(1)); app.data.readarr_data.quality_profile_map = BiMap::from_iter([ (1111, "Lossless".to_owned()), (2222, "HD - 1080p".to_owned()), @@ -1349,9 +1363,9 @@ mod tests { EditAuthorParams { author_id: 42, monitored: Some(false), - monitor_new_items: Some(NewItemMonitorType::All), - quality_profile_id: Some(1111), - metadata_profile_id: Some(3333), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(2222), + metadata_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditAuthorParams::default() diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index ebd1f1dd..515805c8 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -571,11 +571,8 @@ mod tests { #[test] fn test_toggle_monitoring_key() { let mut app = App::test_default(); - app - .data - .readarr_data - .authors - .set_items(vec![Author::default()]); + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); app.is_routing = false; @@ -592,7 +589,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.readarr_data.prompt_confirm_action, - &ReadarrEvent::ToggleAuthorMonitoring(0) + &ReadarrEvent::ToggleAuthorMonitoring(2) ); } @@ -704,6 +701,40 @@ mod tests { assert!(!app.should_refresh); } + #[test] + fn test_search_authors_key() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(authors_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchAuthors.into()); + } + + #[test] + fn test_filter_authors_key() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(authors_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::FilterAuthors.into()); + } + #[test] fn test_update_all_authors_prompt_confirm() { let mut app = App::test_default(); diff --git a/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs index be9c5ada..c063253c 100644 --- a/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs +++ b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs @@ -1649,8 +1649,8 @@ mod tests { ) .build_add_root_folder_body(); - assert_eq!(add_root_folder_body.default_quality_profile_id, 2222); - assert_eq!(add_root_folder_body.default_metadata_profile_id, 4444); + assert_eq!(add_root_folder_body.default_quality_profile_id, 1111); + assert_eq!(add_root_folder_body.default_metadata_profile_id, 3333); } #[test] @@ -1713,15 +1713,28 @@ mod tests { add_root_folder_modal .quality_profile_list .set_items(vec!["EPUB".to_owned(), "MOBI".to_owned()]); + add_root_folder_modal + .quality_profile_list + .state + .select(Some(1)); add_root_folder_modal .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Comprehensive".to_owned()]); + add_root_folder_modal + .metadata_profile_list + .state + .select(Some(1)); add_root_folder_modal .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder_modal.monitor_list.state.select(Some(1)); add_root_folder_modal .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder_modal + .monitor_new_items_list + .state + .select(Some(1)); add_root_folder_modal } @@ -1739,10 +1752,10 @@ mod tests { AddReadarrRootFolderBody { name: "Test Name".to_owned(), path: "/nfs/Test Path".to_owned(), - default_quality_profile_id: 1111, - default_metadata_profile_id: 3333, - default_monitor_option: MonitorType::All, - default_new_item_monitor_option: NewItemMonitorType::All, + default_quality_profile_id: 2222, + default_metadata_profile_id: 4444, + default_monitor_option: MonitorType::Future, + default_new_item_monitor_option: NewItemMonitorType::None, default_tags: Vec::new(), tag_input_string: Some("usenet, testing".to_owned()), } From d08f7d4b4e9d7138e45a1294a6ad92f4db0ca712 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 16:31:05 -0600 Subject: [PATCH 112/170] test: give the shared IndexerSettings fixture distinct field values minimum_age and retention were both 1, so transposing them in any code that reads both rendered identically and no test could tell the difference. id was also 1, which cannot distinguish a correctly threaded value from a hardcode. Verified by transposing minimum_age and retention in the Sonarr indexer settings UI: that mutation survived the full suite before this change and is now caught by the settings snapshot. --- src/network/servarr_test_utils.rs | 6 +++--- .../indexers/sonarr_indexers_network_tests.rs | 6 +++--- ...tests__indexer_settings_ui_renders_indexer_settings.snap | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/network/servarr_test_utils.rs b/src/network/servarr_test_utils.rs index 23c0872c..d81d96dd 100644 --- a/src/network/servarr_test_utils.rs +++ b/src/network/servarr_test_utils.rs @@ -12,9 +12,9 @@ pub fn diskspace() -> DiskSpace { pub fn indexer_settings() -> IndexerSettings { IndexerSettings { - id: 1, - minimum_age: 1, - retention: 1, + id: 7, + minimum_age: 13, + retention: 21, maximum_size: 12345, rss_sync_interval: 60, } diff --git a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs index 2eeaa8a6..5769e4cf 100644 --- a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs +++ b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs @@ -42,10 +42,10 @@ mod tests { #[tokio::test] async fn test_handle_edit_all_indexer_settings_event() { let indexer_settings_json = json!({ - "id": 1, - "minimumAge": 1, + "id": 7, + "minimumAge": 13, "maximumSize": 12345, - "retention": 1, + "retention": 21, "rssSyncInterval": 60 }); let (mock, app, _server) = MockServarrApi::put() diff --git a/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap b/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap index 5452cda8..30dcd865 100644 --- a/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap +++ b/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap @@ -21,10 +21,10 @@ expression: output │ │ │ │ │ ╭────────────────────────────────╮ │ - │ Minimum Age (minutes) ▴▾: │1 │ │ + │ Minimum Age (minutes) ▴▾: │13 │ │ │ ╰────────────────────────────────╯ │ │ ╭────────────────────────────────╮ │ - │ Retention (days) ▴▾: │1 │ │ + │ Retention (days) ▴▾: │21 │ │ │ ╰────────────────────────────────╯ │ │ ╭────────────────────────────────╮ │ │ Maximum Size (MB) ▴▾: │12345 │ │ From f99d4f61f411ce29b46fb7e4dc1ad02ad0229840 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 16:38:36 -0600 Subject: [PATCH 113/170] test: give the tree-local IndexerSettings fixtures distinct field values Sonarr and Lidarr each keep their own copy of the shared fixture, both with minimum_age and retention set to 1, so transposing those two fields stayed invisible in either tree. Radarr built its fixture from IndexerSettings::default(), leaving availability_delay, maximum_size, minimum_age and retention all 0: four mutually transposable fields, none of which could distinguish a threaded value from a hardcoded 0. The no-op guard test seeded IndexerSettings::default() as its sentinel, which cannot show whether the guard held or the field was simply never written. It now seeds a distinct stale_indexer_settings(), mirroring the Readarr convention. Verified by transposing minimum_age and retention in the Lidarr indexer settings UI, now caught by its snapshot, and by inverting the Radarr no-op guard, now caught by both GetAllIndexerSettings tests. --- .../sonarr_handler_test_utils.rs | 6 +-- .../indexers/lidarr_indexers_network_tests.rs | 6 +-- .../lidarr_network_test_utils.rs | 6 +-- .../indexers/radarr_indexers_network_tests.rs | 42 +++++++++---------- .../radarr_network_test_utils.rs | 25 +++++++++-- ..._settings_ui_renders_indexer_settings.snap | 4 +- 6 files changed, 54 insertions(+), 35 deletions(-) diff --git a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs index fca77ae4..fe433443 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs @@ -274,9 +274,9 @@ pub(in crate::handlers::sonarr_handlers) mod utils { pub fn indexer_settings() -> IndexerSettings { IndexerSettings { - id: 1, - minimum_age: 1, - retention: 1, + id: 7, + minimum_age: 13, + retention: 21, maximum_size: 12345, rss_sync_interval: 60, } diff --git a/src/network/lidarr_network/indexers/lidarr_indexers_network_tests.rs b/src/network/lidarr_network/indexers/lidarr_indexers_network_tests.rs index 3ad41fd1..5c4ac26d 100644 --- a/src/network/lidarr_network/indexers/lidarr_indexers_network_tests.rs +++ b/src/network/lidarr_network/indexers/lidarr_indexers_network_tests.rs @@ -44,10 +44,10 @@ mod tests { #[tokio::test] async fn test_handle_edit_all_indexer_settings_event() { let indexer_settings_json = json!({ - "id": 1, - "minimumAge": 1, + "id": 5, + "minimumAge": 11, "maximumSize": 12345, - "retention": 1, + "retention": 29, "rssSyncInterval": 60 }); let (mock, app, _server) = MockServarrApi::put() diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index bd956a26..42e673eb 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -337,9 +337,9 @@ pub mod test_utils { pub fn indexer_settings() -> IndexerSettings { IndexerSettings { - id: 1, - minimum_age: 1, - retention: 1, + id: 5, + minimum_age: 11, + retention: 29, maximum_size: 12345, rss_sync_interval: 60, } diff --git a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs index 7a8b4110..94a7386d 100644 --- a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs +++ b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs @@ -8,7 +8,7 @@ mod tests { use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::radarr_network_test_utils::test_utils::{ - indexer, indexer_settings, + indexer, indexer_settings, stale_indexer_settings, }; use bimap::BiMap; use mockito::Matcher; @@ -36,15 +36,15 @@ mod tests { #[tokio::test] async fn test_handle_edit_all_radarr_indexer_settings_event() { let indexer_settings_json = json!({ - "minimumAge": 0, - "maximumSize": 0, - "retention": 0, + "minimumAge": 27, + "maximumSize": 18400, + "retention": 44, "rssSyncInterval": 60, "preferIndexerFlags": false, - "availabilityDelay": 0, + "availabilityDelay": 19, "allowHardcodedSubs": true, - "whitelistedHardcodedSubs": "", - "id": 1 + "whitelistedHardcodedSubs": "eng", + "id": 3 }); let (async_server, app, _server) = MockServarrApi::put() .with_request_body(indexer_settings_json) @@ -641,15 +641,15 @@ mod tests { #[tokio::test] async fn test_handle_get_all_indexer_settings_event() { let indexer_settings_response_json = json!({ - "minimumAge": 0, - "maximumSize": 0, - "retention": 0, + "minimumAge": 27, + "maximumSize": 18400, + "retention": 44, "rssSyncInterval": 60, "preferIndexerFlags": false, - "availabilityDelay": 0, + "availabilityDelay": 19, "allowHardcodedSubs": true, - "whitelistedHardcodedSubs": "", - "id": 1 + "whitelistedHardcodedSubs": "eng", + "id": 3 }); let response: IndexerSettings = serde_json::from_value(indexer_settings_response_json.clone()).unwrap(); @@ -677,21 +677,21 @@ mod tests { #[tokio::test] async fn test_handle_get_all_indexer_settings_event_no_op_if_already_present() { let indexer_settings_response_json = json!({ - "minimumAge": 0, - "maximumSize": 0, - "retention": 0, + "minimumAge": 27, + "maximumSize": 18400, + "retention": 44, "rssSyncInterval": 60, "preferIndexerFlags": false, - "availabilityDelay": 0, + "availabilityDelay": 19, "allowHardcodedSubs": true, - "whitelistedHardcodedSubs": "", - "id": 1 + "whitelistedHardcodedSubs": "eng", + "id": 3 }); let (async_server, app, _server) = MockServarrApi::get() .returns(indexer_settings_response_json) .build_for(RadarrEvent::GetAllIndexerSettings) .await; - app.lock().await.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); + app.lock().await.data.radarr_data.indexer_settings = Some(stale_indexer_settings()); let mut network = test_network(&app); assert!( @@ -704,7 +704,7 @@ mod tests { async_server.assert_async().await; assert_eq!( app.lock().await.data.radarr_data.indexer_settings, - Some(IndexerSettings::default()) + Some(stale_indexer_settings()) ); } diff --git a/src/network/radarr_network/radarr_network_test_utils.rs b/src/network/radarr_network/radarr_network_test_utils.rs index 8888dbdb..81f11f23 100644 --- a/src/network/radarr_network/radarr_network_test_utils.rs +++ b/src/network/radarr_network/radarr_network_test_utils.rs @@ -421,10 +421,29 @@ pub mod test_utils { pub fn indexer_settings() -> IndexerSettings { IndexerSettings { - rss_sync_interval: 60, allow_hardcoded_subs: true, - id: 1, - ..IndexerSettings::default() + availability_delay: 19, + id: 3, + maximum_size: 18400, + minimum_age: 27, + prefer_indexer_flags: false, + retention: 44, + rss_sync_interval: 60, + whitelisted_hardcoded_subs: "eng".into(), + } + } + + pub fn stale_indexer_settings() -> IndexerSettings { + IndexerSettings { + allow_hardcoded_subs: false, + availability_delay: 31, + id: 8, + maximum_size: 22750, + minimum_age: 52, + prefer_indexer_flags: true, + retention: 66, + rss_sync_interval: 90, + whitelisted_hardcoded_subs: "deu".into(), } } diff --git a/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap b/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap index 7f375933..f43387ba 100644 --- a/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap +++ b/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap @@ -21,10 +21,10 @@ expression: output │ │ │ │ │ ╭────────────────────────────────╮ │ - │ Minimum Age (minutes) ▴▾: │1 │ │ + │ Minimum Age (minutes) ▴▾: │11 │ │ │ ╰────────────────────────────────╯ │ │ ╭────────────────────────────────╮ │ - │ Retention (days) ▴▾: │1 │ │ + │ Retention (days) ▴▾: │29 │ │ │ ╰────────────────────────────────╯ │ │ ╭────────────────────────────────╮ │ │ Maximum Size (MB) ▴▾: │12345 │ │ From 3e414f9f0c987c4d88b40a8a567cabd020fd5663 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 16:43:25 -0600 Subject: [PATCH 114/170] test: give the CLI indexer settings tests distinct field values The Radarr, Sonarr and Lidarr tests for editing all indexer settings expected every numeric field to be 1 and returned every previous field as 2. Because the fields were mutually equal, transposing any two of availability_delay, maximum_size, minimum_age, retention and rss_sync_interval in the CLI parameter builder produced an identical request and no test could tell. Radarr additionally expected both boolean flags to hold the same value, hiding a transposition of those two. Readarr already used distinct values and was the model followed here. Verified by transposing minimum_age and retention in all four CLI parameter builders at once: before this change only the two Readarr tests failed, while Radarr, Sonarr and Lidarr stayed green. After it, the same mutation is caught by five tests across the three trees. --- src/cli/lidarr/edit_command_handler_tests.rs | 26 +++--- src/cli/radarr/edit_command_handler_tests.rs | 90 ++++++++++---------- src/cli/sonarr/edit_command_handler_tests.rs | 26 +++--- 3 files changed, 71 insertions(+), 71 deletions(-) diff --git a/src/cli/lidarr/edit_command_handler_tests.rs b/src/cli/lidarr/edit_command_handler_tests.rs index ab44b310..37a9dcf5 100644 --- a/src/cli/lidarr/edit_command_handler_tests.rs +++ b/src/cli/lidarr/edit_command_handler_tests.rs @@ -613,10 +613,10 @@ mod tests { async fn test_handle_edit_all_indexer_settings_command() { let expected_edit_all_indexer_settings = IndexerSettings { id: 1, - maximum_size: 1, - minimum_age: 1, - retention: 1, - rss_sync_interval: 1, + maximum_size: 12345, + minimum_age: 11, + retention: 29, + rss_sync_interval: 60, }; let mut mock_network = MockNetworkTrait::new(); mock_network @@ -628,11 +628,11 @@ mod tests { .returning(|_| { Ok(Serdeable::Lidarr(LidarrSerdeable::IndexerSettings( IndexerSettings { - id: 1, - maximum_size: 2, - minimum_age: 2, - retention: 2, - rss_sync_interval: 2, + id: 5, + maximum_size: 22222, + minimum_age: 34, + retention: 48, + rss_sync_interval: 90, }, ))) }); @@ -649,10 +649,10 @@ mod tests { }); let app_arc = Arc::new(Mutex::new(App::test_default())); let edit_all_indexer_settings_command = LidarrEditCommand::AllIndexerSettings { - maximum_size: Some(1), - minimum_age: Some(1), - retention: Some(1), - rss_sync_interval: Some(1), + maximum_size: Some(12345), + minimum_age: Some(11), + retention: Some(29), + rss_sync_interval: Some(60), }; let result = LidarrEditCommandHandler::with( diff --git a/src/cli/radarr/edit_command_handler_tests.rs b/src/cli/radarr/edit_command_handler_tests.rs index 02a697a3..998f96f8 100644 --- a/src/cli/radarr/edit_command_handler_tests.rs +++ b/src/cli/radarr/edit_command_handler_tests.rs @@ -833,13 +833,13 @@ mod tests { async fn test_handle_edit_all_indexer_settings_command() { let expected_edit_all_indexer_settings = IndexerSettings { allow_hardcoded_subs: true, - availability_delay: 1, + availability_delay: 19, id: 1, - maximum_size: 1, - minimum_age: 1, + maximum_size: 18400, + minimum_age: 27, prefer_indexer_flags: true, - retention: 1, - rss_sync_interval: 1, + retention: 44, + rss_sync_interval: 60, whitelisted_hardcoded_subs: "test".into(), }; let mut mock_network = MockNetworkTrait::new(); @@ -853,13 +853,13 @@ mod tests { Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings( IndexerSettings { allow_hardcoded_subs: false, - availability_delay: 2, - id: 1, - maximum_size: 2, - minimum_age: 2, + availability_delay: 31, + id: 8, + maximum_size: 22750, + minimum_age: 52, prefer_indexer_flags: false, - retention: 2, - rss_sync_interval: 2, + retention: 66, + rss_sync_interval: 90, whitelisted_hardcoded_subs: "testing".into(), }, ))) @@ -879,13 +879,13 @@ mod tests { let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings { allow_hardcoded_subs: true, disable_allow_hardcoded_subs: false, - availability_delay: Some(1), - maximum_size: Some(1), - minimum_age: Some(1), + availability_delay: Some(19), + maximum_size: Some(18400), + minimum_age: Some(27), prefer_indexer_flags: true, disable_prefer_indexer_flags: false, - retention: Some(1), - rss_sync_interval: Some(1), + retention: Some(44), + rss_sync_interval: Some(60), whitelisted_subtitle_tags: Some("test".to_owned()), }; @@ -904,13 +904,13 @@ mod tests { async fn test_handle_edit_all_indexer_settings_command_disable_flags_function_properly() { let expected_edit_all_indexer_settings = IndexerSettings { allow_hardcoded_subs: false, - availability_delay: 1, + availability_delay: 19, id: 1, - maximum_size: 1, - minimum_age: 1, + maximum_size: 18400, + minimum_age: 27, prefer_indexer_flags: false, - retention: 1, - rss_sync_interval: 1, + retention: 44, + rss_sync_interval: 60, whitelisted_hardcoded_subs: "test".into(), }; let mut mock_network = MockNetworkTrait::new(); @@ -924,13 +924,13 @@ mod tests { Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings( IndexerSettings { allow_hardcoded_subs: true, - availability_delay: 2, - id: 1, - maximum_size: 2, - minimum_age: 2, + availability_delay: 31, + id: 8, + maximum_size: 22750, + minimum_age: 52, prefer_indexer_flags: true, - retention: 2, - rss_sync_interval: 2, + retention: 66, + rss_sync_interval: 90, whitelisted_hardcoded_subs: "testing".into(), }, ))) @@ -950,13 +950,13 @@ mod tests { let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings { allow_hardcoded_subs: false, disable_allow_hardcoded_subs: true, - availability_delay: Some(1), - maximum_size: Some(1), - minimum_age: Some(1), + availability_delay: Some(19), + maximum_size: Some(18400), + minimum_age: Some(27), prefer_indexer_flags: false, disable_prefer_indexer_flags: true, - retention: Some(1), - rss_sync_interval: Some(1), + retention: Some(44), + rss_sync_interval: Some(60), whitelisted_subtitle_tags: Some("test".to_owned()), }; @@ -976,13 +976,13 @@ mod tests { { let expected_edit_all_indexer_settings = IndexerSettings { allow_hardcoded_subs: true, - availability_delay: 2, + availability_delay: 31, id: 1, - maximum_size: 2, - minimum_age: 2, - prefer_indexer_flags: true, - retention: 2, - rss_sync_interval: 2, + maximum_size: 22750, + minimum_age: 52, + prefer_indexer_flags: false, + retention: 66, + rss_sync_interval: 90, whitelisted_hardcoded_subs: "testing".into(), }; let mut mock_network = MockNetworkTrait::new(); @@ -996,13 +996,13 @@ mod tests { Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings( IndexerSettings { allow_hardcoded_subs: true, - availability_delay: 2, - id: 1, - maximum_size: 2, - minimum_age: 2, - prefer_indexer_flags: true, - retention: 2, - rss_sync_interval: 2, + availability_delay: 31, + id: 8, + maximum_size: 22750, + minimum_age: 52, + prefer_indexer_flags: false, + retention: 66, + rss_sync_interval: 90, whitelisted_hardcoded_subs: "testing".into(), }, ))) diff --git a/src/cli/sonarr/edit_command_handler_tests.rs b/src/cli/sonarr/edit_command_handler_tests.rs index 69644513..af318771 100644 --- a/src/cli/sonarr/edit_command_handler_tests.rs +++ b/src/cli/sonarr/edit_command_handler_tests.rs @@ -632,10 +632,10 @@ mod tests { async fn test_handle_edit_all_indexer_settings_command() { let expected_edit_all_indexer_settings = IndexerSettings { id: 1, - maximum_size: 1, - minimum_age: 1, - retention: 1, - rss_sync_interval: 1, + maximum_size: 12345, + minimum_age: 13, + retention: 21, + rss_sync_interval: 60, }; let mut mock_network = MockNetworkTrait::new(); mock_network @@ -647,11 +647,11 @@ mod tests { .returning(|_| { Ok(Serdeable::Sonarr(SonarrSerdeable::IndexerSettings( IndexerSettings { - id: 1, - maximum_size: 2, - minimum_age: 2, - retention: 2, - rss_sync_interval: 2, + id: 9, + maximum_size: 22222, + minimum_age: 33, + retention: 47, + rss_sync_interval: 90, }, ))) }); @@ -668,10 +668,10 @@ mod tests { }); let app_arc = Arc::new(Mutex::new(App::test_default())); let edit_all_indexer_settings_command = SonarrEditCommand::AllIndexerSettings { - maximum_size: Some(1), - minimum_age: Some(1), - retention: Some(1), - rss_sync_interval: Some(1), + maximum_size: Some(12345), + minimum_age: Some(13), + retention: Some(21), + rss_sync_interval: Some(60), }; let result = SonarrEditCommandHandler::with( From b9d96ce235529fdac1be29aff9350b8432e3db79 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 16:46:50 -0600 Subject: [PATCH 115/170] test(readarr): give each history fixture row a distinct quality Two of the three rows carried the quality EPUB, so sorting by quality left them tied and any reordering confined to that pair was invisible. The rows now use EPUB, AZW3 and MOBI, matching the vocabulary the history UI fixture already uses. This one is defensive: no natural mutation of the quality comparator flipped from surviving to failing, because two distinct values already separated the cross-field cases. The tie is removed on principle rather than to close a demonstrated hole. --- src/handlers/readarr_handlers/history/history_handler_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handlers/readarr_handlers/history/history_handler_tests.rs b/src/handlers/readarr_handlers/history/history_handler_tests.rs index 66ed7d16..d29b66cb 100644 --- a/src/handlers/readarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/readarr_handlers/history/history_handler_tests.rs @@ -417,7 +417,7 @@ mod tests { event_type: ReadarrHistoryEventType::BookFileDeleted, quality: QualityWrapper { quality: Quality { - name: "EPUB".to_owned(), + name: "MOBI".to_owned(), }, }, date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), From e51b9d72b12346adda1a3d00b517987af41609d2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 16:55:28 -0600 Subject: [PATCH 116/170] test(radarr): pin which table build_add_movie_body reads from The handler fixtures for a collection movie and a searched movie both carried tmdb_id 1234 and the title Test, so the two branches of build_add_movie_body were value equivalent. The builder test also populated only the table its branch needed, so swapping the branches was caught by an unwrap panic on the empty table rather than by any assertion on the values. The searched movie fixture now has its own identity, and the builder test seeds both tables so the branch is chosen against real competing data. Swapping the branches now fails with a value diff naming the wrong tmdb_id and title. Only the handler copy of the fixture is changed. The network copy deliberately shares tmdb_id 1234 with the movie fixture, which is what drives the already in library marker in the add movie snapshots, so changing it there would have silently removed that marker. --- .../library/add_movie_handler_tests.rs | 63 ++++++++++++------- .../radarr_handler_test_utils.rs | 4 +- 2 files changed, 41 insertions(+), 26 deletions(-) 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 62e6daf5..2a49c611 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -18,7 +18,7 @@ mod tests { use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::collection_movie; use crate::models::HorizontallyScrollableText; use crate::models::radarr_models::{ - AddMovieSearchResult, CollectionMovie, MinimumAvailability, MovieMonitor, + AddMovieBody, AddMovieSearchResult, CollectionMovie, MinimumAvailability, MovieMonitor, }; use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ADD_MOVIE_BLOCKS, ActiveRadarrBlock}; @@ -1076,7 +1076,7 @@ mod tests { assert_navigation_popped!(app, ActiveRadarrBlock::Movies.into()); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::AddMovie(add_movie_body()) + &RadarrEvent::AddMovie(expected_add_movie_body(movie_details_context)) ); assert_modal_absent!(app.data.radarr_data.add_movie_modal); } @@ -1517,7 +1517,7 @@ mod tests { assert_navigation_popped!(app, ActiveRadarrBlock::Movies.into()); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::AddMovie(add_movie_body()) + &RadarrEvent::AddMovie(expected_add_movie_body(movie_details_context)) ); assert_modal_absent!(app.data.radarr_data.add_movie_modal); } @@ -1609,29 +1609,29 @@ mod tests { app.data.radarr_data.add_movie_modal = Some(add_movie_modal); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.radarr_data.collection_movies.set_items(vec![ + CollectionMovie { + tmdb_id: 5678, + title: HorizontallyScrollableText::from("Some Other Movie"), + ..CollectionMovie::default() + }, + collection_movie(), + ]); + app.data.radarr_data.collection_movies.select_index(Some(1)); + let mut add_searched_movies = StatefulTable::default(); + add_searched_movies.set_items(vec![ + AddMovieSearchResult { + tmdb_id: 5678, + title: HorizontallyScrollableText::from("Some Other Movie"), + ..AddMovieSearchResult::default() + }, + add_movie_search_result(), + ]); + add_searched_movies.select_index(Some(1)); + app.data.radarr_data.add_searched_movies = Some(add_searched_movies); let context = if movie_details_context { - app.data.radarr_data.collection_movies.set_items(vec![ - CollectionMovie { - tmdb_id: 5678, - title: HorizontallyScrollableText::from("Some Other Movie"), - ..CollectionMovie::default() - }, - collection_movie(), - ]); - app.data.radarr_data.collection_movies.select_index(Some(1)); Some(ActiveRadarrBlock::CollectionDetails) } else { - let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(vec![ - AddMovieSearchResult { - tmdb_id: 5678, - title: HorizontallyScrollableText::from("Some Other Movie"), - ..AddMovieSearchResult::default() - }, - add_movie_search_result(), - ]); - add_searched_movies.select_index(Some(1)); - app.data.radarr_data.add_searched_movies = Some(add_searched_movies); None }; @@ -1643,7 +1643,10 @@ mod tests { ) .build_add_movie_body(); - assert_eq!(actual_add_movie_body, add_movie_body()); + assert_eq!( + actual_add_movie_body, + expected_add_movie_body(movie_details_context) + ); } #[test] @@ -1675,4 +1678,16 @@ mod tests { assert!(handler.is_ready()); } + + fn expected_add_movie_body(movie_details_context: bool) -> AddMovieBody { + if movie_details_context { + add_movie_body() + } else { + AddMovieBody { + tmdb_id: 4321, + title: "Test Search Result".to_owned(), + ..add_movie_body() + } + } + } } diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index 82241948..77a0382e 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -392,8 +392,8 @@ pub(in crate::handlers::radarr_handlers) mod utils { pub fn add_movie_search_result() -> AddMovieSearchResult { AddMovieSearchResult { - tmdb_id: 1234, - title: HorizontallyScrollableText::from("Test"), + tmdb_id: 4321, + title: HorizontallyScrollableText::from("Test Search Result"), original_language: language(), status: "released".to_owned(), overview: "New movie blah blah blah".to_owned(), From 78f5045844e26f1c6a470c21322d7013fa0c43fe Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 17:00:49 -0600 Subject: [PATCH 117/170] test: pin extract_task_name against a real selected task Both tests seeded a single default task and asserted the extracted name equalled the default task name, so a body that ignored the table entirely and returned the default satisfied them. Radarr and Readarr already seed distinct named tasks and select one; these two now follow that shape. Verified by replacing both bodies with a bare default task name. The Lidarr test could not have failed before, since it compared the default against itself, and the run confirms it is the only Lidarr test that catches the change. The Sonarr body was already caught by the two start task prompt tests, so there the dedicated test is strengthened rather than newly load bearing. --- .../system/system_details_handler_tests.rs | 18 ++++++++++++------ .../system/system_details_handler_tests.rs | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/handlers/lidarr_handlers/system/system_details_handler_tests.rs b/src/handlers/lidarr_handlers/system/system_details_handler_tests.rs index 0487ef52..03749144 100644 --- a/src/handlers/lidarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/system/system_details_handler_tests.rs @@ -970,11 +970,17 @@ mod tests { #[test] fn test_extract_task_name() { let mut app = App::test_default(); - app - .data - .lidarr_data - .tasks - .set_items(vec![LidarrTask::default()]); + app.data.lidarr_data.tasks.set_items(vec![ + LidarrTask { + task_name: LidarrTaskName::Backup, + ..LidarrTask::default() + }, + LidarrTask { + task_name: LidarrTaskName::RefreshArtist, + ..LidarrTask::default() + }, + ]); + app.data.lidarr_data.tasks.select_index(Some(1)); let task_name = SystemDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -984,7 +990,7 @@ mod tests { ) .extract_task_name(); - assert_eq!(task_name, LidarrTaskName::default()); + assert_eq!(task_name, LidarrTaskName::RefreshArtist); } #[test] 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 6874edec..49d54511 100644 --- a/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs @@ -984,11 +984,17 @@ mod tests { #[test] fn test_extract_task_name() { let mut app = App::test_default(); - app - .data - .sonarr_data - .tasks - .set_items(vec![SonarrTask::default()]); + app.data.sonarr_data.tasks.set_items(vec![ + SonarrTask { + task_name: SonarrTaskName::Backup, + ..SonarrTask::default() + }, + SonarrTask { + task_name: SonarrTaskName::RefreshSeries, + ..SonarrTask::default() + }, + ]); + app.data.sonarr_data.tasks.select_index(Some(1)); let task_name = SystemDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -998,7 +1004,7 @@ mod tests { ) .extract_task_name(); - assert_eq!(task_name, SonarrTaskName::default()); + assert_eq!(task_name, SonarrTaskName::RefreshSeries); } #[test] From 08018e4457962389510af8bcdbc0cb1da3ca7a37 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 9 Sep 2026 17:03:28 -0600 Subject: [PATCH 118/170] test(readarr): give the quality and metadata profile fixtures distinct values ReadarrData::test_default_fully_populated seeded both profile maps with the same id and the same name, and gave the author fixture the same id for both profiles. The library table renders the two profiles in adjacent columns, so both read Standard and swapping the maps produced identical output. The maps now use the disjoint ids and the vocabulary the root folder and add author tests already use, so the columns read Standard and Comprehensive. Verified by swapping the two map lookups where the authors table is built. That swap survived the whole suite before this change and is now caught by sixteen snapshots. --- src/models/servarr_data/readarr/readarr_data.rs | 8 ++++---- ...s__snapshot_tests__author_details_AuthorDetails_0.snap | 2 +- ...shot_tests__author_details_AuthorHistoryDetails_1.snap | 2 +- ...t_tests__author_details_AuthorHistorySortPrompt_1.snap | 2 +- ...s__snapshot_tests__author_details_AuthorHistory_1.snap | 2 +- ..._author_details_AutomaticallySearchAuthorPrompt_0.snap | 2 +- ..._author_details_AutomaticallySearchAuthorPrompt_1.snap | 2 +- ..._author_details_AutomaticallySearchAuthorPrompt_2.snap | 2 +- ..._tests__author_details_FilterAuthorHistoryError_1.snap | 2 +- ...pshot_tests__author_details_FilterAuthorHistory_1.snap | 2 +- ..._author_details_ManualAuthorSearchConfirmPrompt_2.snap | 2 +- ...ts__author_details_ManualAuthorSearchSortPrompt_2.snap | 2 +- ...apshot_tests__author_details_ManualAuthorSearch_2.snap | 2 +- ..._tests__author_details_SearchAuthorHistoryError_1.snap | 2 +- ...pshot_tests__author_details_SearchAuthorHistory_1.snap | 2 +- ...snapshot_tests__author_details_SearchBooksError_0.snap | 2 +- ...sts__snapshot_tests__author_details_SearchBooks_0.snap | 2 +- ...tests__author_details_UpdateAndScanAuthorPrompt_0.snap | 2 +- ...tests__author_details_UpdateAndScanAuthorPrompt_1.snap | 2 +- ...tests__author_details_UpdateAndScanAuthorPrompt_2.snap | 2 +- ...nders_automatic_search_prompt_over_author_details.snap | 2 +- ...etails_ui_renders_delete_book_over_author_details.snap | 2 +- ...pshot_tests__author_details_ui_renders_statistics.snap | 2 +- ...enders_update_and_scan_prompt_over_author_details.snap | 2 +- ...napshot_tests__empty_author_details_AuthorDetails.snap | 2 +- ...napshot_tests__empty_author_details_AuthorHistory.snap | 2 +- ..._tests__empty_author_details_AuthorHistoryDetails.snap | 2 +- ...ot_tests__empty_author_details_ManualAuthorSearch.snap | 2 +- ...pshot_tests__loading_author_details_AuthorDetails.snap | 2 +- ...pshot_tests__loading_author_details_AuthorHistory.snap | 2 +- ..._tests__loading_author_details_ManualAuthorSearch.snap | 2 +- ...tests__edit_author_ui_renders_over_author_details.snap | 2 +- ...tests__library_ui_renders_add_author_over_library.snap | 2 +- ...s__library_ui_renders_author_details_over_library.snap | 4 ++-- ...ts__library_ui_renders_delete_author_over_library.snap | 2 +- ...ests__library_ui_renders_edit_author_over_library.snap | 2 +- ...sts__library_ui_renders_update_all_authors_prompt.snap | 2 +- ...s__tests__snapshot_tests__readarr_library_Authors.snap | 2 +- ...snapshot_tests__readarr_library_AuthorsSortPrompt.snap | 2 +- ...ts__snapshot_tests__readarr_library_FilterAuthors.snap | 2 +- ...napshot_tests__readarr_library_FilterAuthorsError.snap | 2 +- ...ts__snapshot_tests__readarr_library_SearchAuthors.snap | 2 +- ...napshot_tests__readarr_library_SearchAuthorsError.snap | 2 +- ...ests__tests__snapshot_tests__readarr_tabs_Authors.snap | 2 +- ...s__snapshot_tests__readarr_ui_renders_library_tab.snap | 2 +- ..._tests__readarr_ui_renders_library_tab_with_error.snap | 2 +- ...ts__readarr_ui_renders_notification_failure_popup.snap | 2 +- ...ts__readarr_ui_renders_notification_success_popup.snap | 2 +- 48 files changed, 52 insertions(+), 52 deletions(-) diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index 4162aa05..d8cb5f26 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -245,8 +245,8 @@ impl<'a> Default for ReadarrData<'a> { #[cfg(test)] impl ReadarrData<'_> { pub fn test_default_fully_populated() -> Self { - let quality_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]); - let metadata_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]); + let quality_profile_map = BiMap::from_iter([(1111i64, "Standard".to_owned())]); + let metadata_profile_map = BiMap::from_iter([(3333i64, "Comprehensive".to_owned())]); let tags_map = BiMap::from_iter([(1i64, "alex".to_owned())]); let root_folder = RootFolder { id: 1, @@ -275,8 +275,8 @@ impl ReadarrData<'_> { .to_owned(), ), path: "/nfs/books/Test Author".to_owned(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 1111, + metadata_profile_id: 3333, monitored: true, monitor_new_items: NewItemMonitorType::All, genres: vec!["science fiction".to_owned()], diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap index 42cd616f..3f888ea3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap index 894486a6..1c6d049d 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap index 9c5a6818..8d2e3538 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap index 8a4b2a62..41abe9ed 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap index b511dc00..09682797 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap index 79d6d417..a68e71a7 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap index 5b2ea803..8695edaf 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap index d45da62e..e3c11487 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap index 19a84629..b50638d0 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap index 49b8043e..848c8c22 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap index af676b20..b8c3a0d3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap index 9613ce4f..91a5d219 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap index 0a17a013..c80452c2 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap index fd17c74d..b96965ce 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap index 68f27a1c..fede133e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap index 3ef32721..07159d3e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap index 0c096c2f..a19dea43 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap index 71d50e3a..bdd6acc2 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap index 4aa3a45d..a2effcc8 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap index b511dc00..09682797 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap index ec5d4151..4ea2aa15 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap index 4c3bf601..762a0298 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Books: 3 │ │Files: 2/3 │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap index 0c096c2f..a19dea43 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap index e81cb685..dd9d38b4 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap index e81cb685..dd9d38b4 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap index 90609ffe..d461fa91 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap index 85109ff3..c6ecfcb5 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap index 85109ff3..c6ecfcb5 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap index 85109ff3..c6ecfcb5 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap index 85109ff3..c6ecfcb5 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap index 53654aca..10fe588c 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │ │ │Path: /nfs/books/Te│ │ │ │Quality Profile: St│ │ │ - │Metadata Profile: S│ │ │ + │Metadata Profile: C│ │ │ │Monitored: Yes │ │ │ │Overview: some inte│ │st novel took him │ │seven years to fini│ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap index 45145085..ffe0ab6c 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap index 5a645d25..262fed8e 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ │Status: Continuing │ @@ -12,7 +12,7 @@ expression: output │Rating: N/A │ │Path: /nfs/books/Test Author │ │Quality Profile: Standard │ - │Metadata Profile: Standard │ + │Metadata Profile: Comprehensive │ │Monitored: Yes │ │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ │seven years to finish. │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap index 31bd8c98..b81749cd 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap index 47731f7c..914bcf22 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap index ced04cc7..df266ef9 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap index 235492ff..17605ff3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap @@ -4,4 +4,4 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap index 63f8420f..21b5d910 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap index c1fd94a0..f41de851 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap index 59d973b7..82929938 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap index d3e05746..b473f952 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap index f4d36c07..bf0cd322 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap index 5b8d5494..3287bf78 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap @@ -6,7 +6,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap index 46d16cac..1c1261ed 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap index e32ecd57..19225520 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap @@ -22,7 +22,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap index 5b19748e..8ea9985c 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap index 0fe7bc59..ebd2dc03 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ │ │ │ │ │ │ From e622be328808af14eab1575d17e881a3842ce15f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 00:41:07 -0600 Subject: [PATCH 119/170] test: assert delegated handlers pop exactly one navigation stack frame test_handler_delegation! asserted only that the current route equalled the base block after Esc. When the delegated block was itself the base block the stack read [base, base], so that assertion held whether or not the sub-handler popped, and the case asserted nothing. Capture the navigation stack depth before delegating and assert it drops by exactly one, which also catches a handler that pops and then re-pushes. Depth is read through a test-only accessor on App; the navigation_stack field stays private. Main tab blocks clear errors on Esc rather than popping, by design, so they cannot be probed this way at all. Drop them from the delegation value lists; the remaining values in each list already exercise the same dispatch arm. --- src/app/mod.rs | 4 ++++ src/handlers/handler_test_utils.rs | 6 ++++++ src/handlers/lidarr_handlers/lidarr_handler_tests.rs | 7 ------- src/handlers/radarr_handlers/radarr_handler_tests.rs | 8 -------- src/handlers/readarr_handlers/readarr_handler_tests.rs | 7 ------- src/handlers/sonarr_handlers/sonarr_handler_tests.rs | 7 ------- 6 files changed, 10 insertions(+), 29 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 14b18339..18baab56 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -371,6 +371,10 @@ impl App<'_> { ..App::default() } } + + pub fn get_navigation_stack_depth(&self) -> usize { + self.navigation_stack.len() + } } #[derive(Default)] diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 592f7c69..4c81d576 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -333,10 +333,16 @@ mod test_utils { let mut app = App::test_default_fully_populated(); app.push_navigation_stack($base.into()); app.push_navigation_stack($active_block.into()); + let navigation_stack_depth_before_delegation = app.get_navigation_stack_depth(); $handler::new(DEFAULT_KEYBINDINGS.esc.key, &mut app, $active_block, None).handle(); pretty_assertions::assert_eq!(app.get_current_route(), $base.into()); + pretty_assertions::assert_eq!( + app.get_navigation_stack_depth(), + navigation_stack_depth_before_delegation - 1, + "Expected the delegated handler to pop exactly one route off the navigation stack" + ); }; } diff --git a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs index 1a507092..b11d2131 100644 --- a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs +++ b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs @@ -160,7 +160,6 @@ mod tests { #[rstest] fn test_delegates_library_blocks_to_library_handler( #[values( - ActiveLidarrBlock::Artists, ActiveLidarrBlock::ArtistsSortPrompt, ActiveLidarrBlock::FilterArtists, ActiveLidarrBlock::FilterArtistsError, @@ -187,7 +186,6 @@ mod tests { #[rstest] fn test_delegates_downloads_blocks_to_downloads_handler( #[values( - ActiveLidarrBlock::Downloads, ActiveLidarrBlock::DeleteDownloadPrompt, ActiveLidarrBlock::UpdateDownloadsPrompt )] @@ -203,7 +201,6 @@ mod tests { #[rstest] fn test_delegates_blocklist_blocks_to_blocklist_handler( #[values( - ActiveLidarrBlock::Blocklist, ActiveLidarrBlock::BlocklistItemDetails, ActiveLidarrBlock::DeleteBlocklistItemPrompt, ActiveLidarrBlock::BlocklistClearAllItemsPrompt, @@ -221,7 +218,6 @@ mod tests { #[rstest] fn test_delegates_history_blocks_to_history_handler( #[values( - ActiveLidarrBlock::History, ActiveLidarrBlock::HistoryItemDetails, ActiveLidarrBlock::HistorySortPrompt, ActiveLidarrBlock::FilterHistory, @@ -241,7 +237,6 @@ mod tests { #[rstest] fn test_delegates_root_folders_blocks_to_root_folders_handler( #[values( - ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::AddRootFolderPrompt, ActiveLidarrBlock::DeleteRootFolderPrompt )] @@ -258,7 +253,6 @@ mod tests { fn test_delegates_indexers_blocks_to_indexers_handler( #[values( ActiveLidarrBlock::DeleteIndexerPrompt, - ActiveLidarrBlock::Indexers, ActiveLidarrBlock::AllIndexerSettingsPrompt, ActiveLidarrBlock::IndexerSettingsConfirmPrompt, ActiveLidarrBlock::IndexerSettingsMaximumSizeInput, @@ -278,7 +272,6 @@ mod tests { #[rstest] fn test_delegates_system_blocks_to_system_handler( #[values( - ActiveLidarrBlock::System, ActiveLidarrBlock::SystemLogs, ActiveLidarrBlock::SystemQueuedEvents, ActiveLidarrBlock::SystemTasks, diff --git a/src/handlers/radarr_handlers/radarr_handler_tests.rs b/src/handlers/radarr_handlers/radarr_handler_tests.rs index 5333f699..58c2ec15 100644 --- a/src/handlers/radarr_handlers/radarr_handler_tests.rs +++ b/src/handlers/radarr_handlers/radarr_handler_tests.rs @@ -123,7 +123,6 @@ mod tests { #[rstest] fn test_delegates_system_blocks_to_system_handler( #[values( - ActiveRadarrBlock::System, ActiveRadarrBlock::SystemLogs, ActiveRadarrBlock::SystemQueuedEvents, ActiveRadarrBlock::SystemTasks, @@ -142,7 +141,6 @@ mod tests { #[rstest] fn test_delegates_library_blocks_to_library_handler( #[values( - ActiveRadarrBlock::Movies, ActiveRadarrBlock::MoviesSortPrompt, ActiveRadarrBlock::SearchMovie, ActiveRadarrBlock::SearchMovieError, @@ -186,7 +184,6 @@ mod tests { #[rstest] fn test_delegates_collections_blocks_to_collections_handler( #[values( - ActiveRadarrBlock::Collections, ActiveRadarrBlock::SearchCollection, ActiveRadarrBlock::CollectionsSortPrompt, ActiveRadarrBlock::SearchCollectionError, @@ -213,7 +210,6 @@ mod tests { fn test_delegates_indexers_blocks_to_indexers_handler( #[values( ActiveRadarrBlock::DeleteIndexerPrompt, - ActiveRadarrBlock::Indexers, ActiveRadarrBlock::AllIndexerSettingsPrompt, ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput, ActiveRadarrBlock::IndexerSettingsConfirmPrompt, @@ -237,7 +233,6 @@ mod tests { #[rstest] fn test_delegates_downloads_blocks_to_downloads_handler( #[values( - ActiveRadarrBlock::Downloads, ActiveRadarrBlock::DeleteDownloadPrompt, ActiveRadarrBlock::UpdateDownloadsPrompt )] @@ -253,7 +248,6 @@ mod tests { #[rstest] fn test_delegates_root_folders_blocks_to_root_folders_handler( #[values( - ActiveRadarrBlock::RootFolders, ActiveRadarrBlock::AddRootFolderPrompt, ActiveRadarrBlock::DeleteRootFolderPrompt )] @@ -269,7 +263,6 @@ mod tests { #[rstest] fn test_delegates_blocklist_blocks_to_blocklist_handler( #[values( - ActiveRadarrBlock::Blocklist, ActiveRadarrBlock::BlocklistItemDetails, ActiveRadarrBlock::DeleteBlocklistItemPrompt, ActiveRadarrBlock::BlocklistClearAllItemsPrompt, @@ -287,7 +280,6 @@ mod tests { #[rstest] fn test_delegates_history_blocks_to_history_handler( #[values( - ActiveRadarrBlock::History, ActiveRadarrBlock::HistoryItemDetails, ActiveRadarrBlock::HistorySortPrompt, ActiveRadarrBlock::FilterHistory, diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index 445aab40..dd8e7aa4 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -160,7 +160,6 @@ mod tests { #[rstest] fn test_delegates_library_blocks_to_library_handler( #[values( - ActiveReadarrBlock::Authors, ActiveReadarrBlock::AuthorsSortPrompt, ActiveReadarrBlock::FilterAuthors, ActiveReadarrBlock::FilterAuthorsError, @@ -180,7 +179,6 @@ mod tests { #[rstest] fn test_delegates_blocklist_blocks_to_blocklist_handler( #[values( - ActiveReadarrBlock::Blocklist, ActiveReadarrBlock::BlocklistItemDetails, ActiveReadarrBlock::DeleteBlocklistItemPrompt, ActiveReadarrBlock::BlocklistClearAllItemsPrompt, @@ -198,7 +196,6 @@ mod tests { #[rstest] fn test_delegates_downloads_blocks_to_downloads_handler( #[values( - ActiveReadarrBlock::Downloads, ActiveReadarrBlock::DeleteDownloadPrompt, ActiveReadarrBlock::UpdateDownloadsPrompt )] @@ -214,7 +211,6 @@ mod tests { #[rstest] fn test_delegates_history_blocks_to_history_handler( #[values( - ActiveReadarrBlock::History, ActiveReadarrBlock::HistoryItemDetails, ActiveReadarrBlock::HistorySortPrompt, ActiveReadarrBlock::SearchHistory, @@ -234,7 +230,6 @@ mod tests { #[rstest] fn test_delegates_root_folders_blocks_to_root_folders_handler( #[values( - ActiveReadarrBlock::RootFolders, ActiveReadarrBlock::AddRootFolderPrompt, ActiveReadarrBlock::DeleteRootFolderPrompt )] @@ -250,7 +245,6 @@ mod tests { #[rstest] fn test_delegates_indexers_blocks_to_indexers_handler( #[values( - ActiveReadarrBlock::Indexers, ActiveReadarrBlock::DeleteIndexerPrompt, ActiveReadarrBlock::TestIndexer, ActiveReadarrBlock::TestAllIndexers, @@ -274,7 +268,6 @@ mod tests { #[rstest] fn test_delegates_system_blocks_to_system_handler( #[values( - ActiveReadarrBlock::System, ActiveReadarrBlock::SystemLogs, ActiveReadarrBlock::SystemQueuedEvents, ActiveReadarrBlock::SystemTasks, diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index 8cc007ee..208e6ded 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -165,7 +165,6 @@ mod tests { ActiveSonarrBlock::SearchSeriesHistory, ActiveSonarrBlock::SearchSeriesHistoryError, ActiveSonarrBlock::SeasonDetails, - ActiveSonarrBlock::Series, ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory, ActiveSonarrBlock::SeriesHistorySortPrompt, @@ -186,7 +185,6 @@ mod tests { #[rstest] fn test_delegates_downloads_blocks_to_downloads_handler( #[values( - ActiveSonarrBlock::Downloads, ActiveSonarrBlock::DeleteDownloadPrompt, ActiveSonarrBlock::UpdateDownloadsPrompt )] @@ -202,7 +200,6 @@ mod tests { #[rstest] fn test_delegates_blocklist_blocks_to_blocklist_handler( #[values( - ActiveSonarrBlock::Blocklist, ActiveSonarrBlock::BlocklistItemDetails, ActiveSonarrBlock::DeleteBlocklistItemPrompt, ActiveSonarrBlock::BlocklistClearAllItemsPrompt, @@ -220,7 +217,6 @@ mod tests { #[rstest] fn test_delegates_history_blocks_to_history_handler( #[values( - ActiveSonarrBlock::History, ActiveSonarrBlock::HistoryItemDetails, ActiveSonarrBlock::HistorySortPrompt, ActiveSonarrBlock::FilterHistory, @@ -240,7 +236,6 @@ mod tests { #[rstest] fn test_delegates_root_folders_blocks_to_root_folders_handler( #[values( - ActiveSonarrBlock::RootFolders, ActiveSonarrBlock::AddRootFolderPrompt, ActiveSonarrBlock::DeleteRootFolderPrompt )] @@ -257,7 +252,6 @@ mod tests { fn test_delegates_indexers_blocks_to_indexers_handler( #[values( ActiveSonarrBlock::DeleteIndexerPrompt, - ActiveSonarrBlock::Indexers, ActiveSonarrBlock::AllIndexerSettingsPrompt, ActiveSonarrBlock::IndexerSettingsConfirmPrompt, ActiveSonarrBlock::IndexerSettingsMaximumSizeInput, @@ -277,7 +271,6 @@ mod tests { #[rstest] fn test_delegates_system_blocks_to_system_handler( #[values( - ActiveSonarrBlock::System, ActiveSonarrBlock::SystemLogs, ActiveSonarrBlock::SystemQueuedEvents, ActiveSonarrBlock::SystemTasks, From 7e8072133c53438d81a86afa4cb8783f9d01767f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 00:49:42 -0600 Subject: [PATCH 120/170] test: pin the updates popup loading guard in sonarr and lidarr draw_updates_popup shows the loading block when update text is already present but a refresh is still in flight. Both existing tests fed empty update text, so they only exercised the first half of the guard and dropping the is_loading conjunct went undetected. --- ...p_is_loading_when_updates_are_present.snap | 48 +++++++++++++++++++ .../system/system_details_ui_tests.rs | 15 ++++++ ...p_is_loading_when_updates_are_present.snap | 48 +++++++++++++++++++ .../system/system_details_ui_tests.rs | 15 ++++++ 4 files changed, 126 insertions(+) create mode 100644 src/ui/lidarr_ui/system/snapshots/managarr__ui__lidarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap create mode 100644 src/ui/sonarr_ui/system/snapshots/managarr__ui__sonarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap diff --git a/src/ui/lidarr_ui/system/snapshots/managarr__ui__lidarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap b/src/ui/lidarr_ui/system/snapshots/managarr__ui__lidarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap new file mode 100644 index 00000000..e51c3939 --- /dev/null +++ b/src/ui/lidarr_ui/system/snapshots/managarr__ui__lidarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/system/system_details_ui_tests.rs b/src/ui/lidarr_ui/system/system_details_ui_tests.rs index a3fafc1b..8af08067 100644 --- a/src/ui/lidarr_ui/system/system_details_ui_tests.rs +++ b/src/ui/lidarr_ui/system/system_details_ui_tests.rs @@ -70,6 +70,21 @@ mod tests { insta::assert_snapshot!(format!("loading_{active_lidarr_block}"), output); } + #[test] + fn test_system_details_ui_updates_popup_is_loading_when_updates_are_present() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::SystemUpdates.into()); + + assert!(!app.data.lidarr_data.updates.get_text().is_empty()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_system_details_ui_updates_popup_loading_when_empty() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/sonarr_ui/system/snapshots/managarr__ui__sonarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap b/src/ui/sonarr_ui/system/snapshots/managarr__ui__sonarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap new file mode 100644 index 00000000..ed21afcf --- /dev/null +++ b/src/ui/sonarr_ui/system/snapshots/managarr__ui__sonarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/system/system_details_ui_tests.rs b/src/ui/sonarr_ui/system/system_details_ui_tests.rs index ec42b046..26558ad7 100644 --- a/src/ui/sonarr_ui/system/system_details_ui_tests.rs +++ b/src/ui/sonarr_ui/system/system_details_ui_tests.rs @@ -70,6 +70,21 @@ mod tests { insta::assert_snapshot!(format!("loading_{active_sonarr_block}"), output); } + #[test] + fn test_system_details_ui_updates_popup_is_loading_when_updates_are_present() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SystemUpdates.into()); + + assert!(!app.data.sonarr_data.updates.get_text().is_empty()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_system_details_ui_updates_popup_loading_when_empty() { let mut app = App::test_default_fully_populated(); From f7c67ad58b942c859d7e19238f3f2e6efd964302 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 00:53:07 -0600 Subject: [PATCH 121/170] test(radarr): cover the updates popup empty state Radarr was the only tree without a test rendering draw_updates_popup with no update text, leaving its loading branch unreachable from the suite. Forcing the guard true went undetected. --- ...ui_renders_updates_loading_when_empty.snap | 48 +++++++++++++++++++ .../system/system_details_ui_tests.rs | 15 ++++++ 2 files changed, 63 insertions(+) create mode 100644 src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_empty.snap diff --git a/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_empty.snap b/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_empty.snap new file mode 100644 index 00000000..c8e5896f --- /dev/null +++ b/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_empty.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/system/system_details_ui_tests.rs b/src/ui/radarr_ui/system/system_details_ui_tests.rs index 2277f3ad..77f5ef37 100644 --- a/src/ui/radarr_ui/system/system_details_ui_tests.rs +++ b/src/ui/radarr_ui/system/system_details_ui_tests.rs @@ -24,6 +24,7 @@ mod tests { mod snapshot_tests { use super::*; + use crate::models::ScrollableText; use rstest::rstest; #[rstest] @@ -85,5 +86,19 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_system_details_ui_renders_updates_loading_when_empty() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveRadarrBlock::SystemUpdates.into()); + app.data.radarr_data.updates = ScrollableText::with_string("".to_string()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } From 5fa521ba7f825d36dabb3c91bcdc1c3da1fb600e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 00:57:55 -0600 Subject: [PATCH 122/170] test: cover indexer settings drawn over the indexers table IndexersUi dispatches to IndexerSettingsUi, but no test rendered that route through the parent, so deleting the dispatch arm in radarr, sonarr and lidarr left the whole suite green. --- .../lidarr_ui/indexers/indexers_ui_tests.rs | 15 +++++++ ...enders_indexer_settings_over_indexers.snap | 40 ++++++++++++++++++ .../radarr_ui/indexers/indexers_ui_tests.rs | 17 ++++++++ ...enders_indexer_settings_over_indexers.snap | 42 +++++++++++++++++++ .../sonarr_ui/indexers/indexers_ui_tests.rs | 15 +++++++ ...enders_indexer_settings_over_indexers.snap | 40 ++++++++++++++++++ 6 files changed, 169 insertions(+) create mode 100644 src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap create mode 100644 src/ui/radarr_ui/indexers/snapshots/managarr__ui__radarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap create mode 100644 src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap diff --git a/src/ui/lidarr_ui/indexers/indexers_ui_tests.rs b/src/ui/lidarr_ui/indexers/indexers_ui_tests.rs index 80191880..f064db82 100644 --- a/src/ui/lidarr_ui/indexers/indexers_ui_tests.rs +++ b/src/ui/lidarr_ui/indexers/indexers_ui_tests.rs @@ -32,6 +32,7 @@ mod tests { use crate::models::BlockSelectionState; use crate::models::servarr_data::lidarr::lidarr_data::{ EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, }; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::indexer; use crate::ui::ui_test_utils::test_utils::TerminalSize; @@ -152,5 +153,19 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_indexers_ui_renders_indexer_settings_over_indexers() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AllIndexerSettingsPrompt.into()); + app.data.lidarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap b/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap new file mode 100644 index 00000000..d6df46e4 --- /dev/null +++ b/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/lidarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │11 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │29 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │12345 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │60 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/indexers/indexers_ui_tests.rs b/src/ui/radarr_ui/indexers/indexers_ui_tests.rs index 19193d3b..553d4095 100644 --- a/src/ui/radarr_ui/indexers/indexers_ui_tests.rs +++ b/src/ui/radarr_ui/indexers/indexers_ui_tests.rs @@ -29,6 +29,9 @@ mod tests { } mod snapshot_tests { + use crate::models::BlockSelectionState; + use crate::models::servarr_data::radarr::radarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS; + use super::*; #[test] @@ -120,5 +123,19 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_indexers_ui_renders_indexer_settings_over_indexers() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.radarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/radarr_ui/indexers/snapshots/managarr__ui__radarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap b/src/ui/radarr_ui/indexers/snapshots/managarr__ui__radarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap new file mode 100644 index 00000000..796f4fd5 --- /dev/null +++ b/src/ui/radarr_ui/indexers/snapshots/managarr__ui__radarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/radarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer ▼ RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + ╭─────────────────────────────────────── Configure All Indexer Settings ────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │12 │ Availability Delay (days) │0 │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Retention (days) ▴▾: │30 │ RSS Sync Interval (minutes│60 │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │1234 │ Whitelisted Subtitle Tags:│eng │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭───╮ │ + │ Prefer Indexer Flags: │ ✔ │ Allow Hardcoded Subs: │ ✔ │ │ + │ ╰───╯ ╰───╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs b/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs index 0db0575e..0cbf8bfc 100644 --- a/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs +++ b/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs @@ -32,6 +32,7 @@ mod tests { use crate::models::BlockSelectionState; use crate::models::servarr_data::sonarr::sonarr_data::{ EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, }; use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::indexer; use crate::ui::ui_test_utils::test_utils::TerminalSize; @@ -152,5 +153,19 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_indexers_ui_renders_indexer_settings_over_indexers() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap b/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap new file mode 100644 index 00000000..de0fee50 --- /dev/null +++ b/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/sonarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │13 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │21 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │12345 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │60 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ From 1a5e5c314510d006a5c5c6fdb432b884d6213bf2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 01:04:07 -0600 Subject: [PATCH 123/170] test: give the delete flags distinct values where params are asserted The delete params tests set the delete files flag and the list exclusion flag both true, so swapping the two fields in the params builder produced identical params and no test noticed. The tests that assert a reset still set both true, since that is what proves both flags are cleared. --- .../library/delete_album_handler_tests.rs | 12 ++++++------ .../library/delete_artist_handler_tests.rs | 12 ++++++------ .../library/delete_movie_handler_tests.rs | 12 ++++++------ .../library/delete_series_handler_tests.rs | 12 ++++++------ 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/handlers/lidarr_handlers/library/delete_album_handler_tests.rs b/src/handlers/lidarr_handlers/library/delete_album_handler_tests.rs index 65ddd494..18068f5a 100644 --- a/src/handlers/lidarr_handlers/library/delete_album_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/delete_album_handler_tests.rs @@ -127,7 +127,7 @@ mod tests { app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); app.push_navigation_stack(ActiveLidarrBlock::DeleteAlbumPrompt.into()); app.data.lidarr_data.prompt_confirm = true; - app.data.lidarr_data.delete_files = true; + app.data.lidarr_data.delete_files = false; app.data.lidarr_data.add_import_list_exclusion = true; app .data @@ -136,7 +136,7 @@ mod tests { .set_items(vec![Album::default()]); let expected_delete_album_params = DeleteParams { id: 0, - delete_files: true, + delete_files: false, add_import_list_exclusion: true, }; app.data.lidarr_data.selected_block = BlockSelectionState::new(DELETE_ALBUM_SELECTION_BLOCKS); @@ -275,7 +275,7 @@ mod tests { app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); app.push_navigation_stack(ActiveLidarrBlock::DeleteAlbumPrompt.into()); app.data.lidarr_data.delete_files = true; - app.data.lidarr_data.add_import_list_exclusion = true; + app.data.lidarr_data.add_import_list_exclusion = false; app .data .lidarr_data @@ -284,7 +284,7 @@ mod tests { let expected_delete_album_params = DeleteParams { id: 0, delete_files: true, - add_import_list_exclusion: true, + add_import_list_exclusion: false, }; app.data.lidarr_data.selected_block = BlockSelectionState::new(DELETE_ALBUM_SELECTION_BLOCKS); app @@ -352,11 +352,11 @@ mod tests { .albums .set_items(vec![Album::default()]); app.data.lidarr_data.delete_files = true; - app.data.lidarr_data.add_import_list_exclusion = true; + app.data.lidarr_data.add_import_list_exclusion = false; let expected_delete_album_params = DeleteParams { id: 0, delete_files: true, - add_import_list_exclusion: true, + add_import_list_exclusion: false, }; let delete_album_params = DeleteAlbumHandler::new( diff --git a/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs index 6c068407..70574dea 100644 --- a/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs @@ -130,7 +130,7 @@ mod tests { app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); app.data.lidarr_data.prompt_confirm = true; - app.data.lidarr_data.delete_files = true; + app.data.lidarr_data.delete_files = false; app.data.lidarr_data.add_import_list_exclusion = true; app .data @@ -139,7 +139,7 @@ mod tests { .set_items(vec![Artist::default()]); let expected_delete_artist_params = DeleteParams { id: 0, - delete_files: true, + delete_files: false, add_import_list_exclusion: true, }; app.data.lidarr_data.selected_block = @@ -280,7 +280,7 @@ mod tests { app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); app.data.lidarr_data.delete_files = true; - app.data.lidarr_data.add_import_list_exclusion = true; + app.data.lidarr_data.add_import_list_exclusion = false; app .data .lidarr_data @@ -289,7 +289,7 @@ mod tests { let expected_delete_artist_params = DeleteParams { id: 0, delete_files: true, - add_import_list_exclusion: true, + add_import_list_exclusion: false, }; app.data.lidarr_data.selected_block = BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); @@ -358,11 +358,11 @@ mod tests { .artists .set_items(vec![Artist::default()]); app.data.lidarr_data.delete_files = true; - app.data.lidarr_data.add_import_list_exclusion = true; + app.data.lidarr_data.add_import_list_exclusion = false; let expected_delete_artist_params = DeleteParams { id: 0, delete_files: true, - add_import_list_exclusion: true, + add_import_list_exclusion: false, }; let delete_artist_params = DeleteArtistHandler::new( 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 1e97b1e3..01bdd1c4 100644 --- a/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs @@ -126,7 +126,7 @@ mod tests { let mut app = App::test_default(); let expected_delete_movie_params = DeleteMovieParams { id: 1, - delete_movie_files: true, + delete_movie_files: false, add_list_exclusion: true, }; app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); @@ -138,7 +138,7 @@ mod tests { .set_items(vec![Movie { id: 999, ..movie() }, movie()]); app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; - app.data.radarr_data.delete_movie_files = true; + app.data.radarr_data.delete_movie_files = false; app.data.radarr_data.add_list_exclusion = true; app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); app @@ -276,7 +276,7 @@ mod tests { let expected_delete_movie_params = DeleteMovieParams { id: 1, delete_movie_files: true, - add_list_exclusion: true, + add_list_exclusion: false, }; app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); @@ -287,7 +287,7 @@ mod tests { .set_items(vec![Movie { id: 999, ..movie() }, movie()]); app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.delete_movie_files = true; - app.data.radarr_data.add_list_exclusion = true; + app.data.radarr_data.add_list_exclusion = false; app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); app .data @@ -355,11 +355,11 @@ mod tests { .set_items(vec![Movie { id: 999, ..movie() }, movie()]); app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.delete_movie_files = true; - app.data.radarr_data.add_list_exclusion = true; + app.data.radarr_data.add_list_exclusion = false; let expected_delete_movie_params = DeleteMovieParams { id: 1, delete_movie_files: true, - add_list_exclusion: true, + add_list_exclusion: false, }; let delete_movie_params = DeleteMovieHandler::new( diff --git a/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs index cc8e2879..d9825a03 100644 --- a/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs @@ -131,7 +131,7 @@ mod tests { 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.delete_series_files = false; app.data.sonarr_data.add_list_exclusion = true; app.data.sonarr_data.series.set_items(vec![ Series { @@ -143,7 +143,7 @@ mod tests { app.data.sonarr_data.series.select_index(Some(1)); let expected_delete_series_params = DeleteSeriesParams { id: 1, - delete_series_files: true, + delete_series_files: false, add_list_exclusion: true, }; app.data.sonarr_data.selected_block = @@ -284,7 +284,7 @@ mod tests { 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.add_list_exclusion = false; app.data.sonarr_data.series.set_items(vec![ Series { id: 999, @@ -296,7 +296,7 @@ mod tests { let expected_delete_series_params = DeleteSeriesParams { id: 1, delete_series_files: true, - add_list_exclusion: true, + add_list_exclusion: false, }; app.data.sonarr_data.selected_block = BlockSelectionState::new(DELETE_SERIES_SELECTION_BLOCKS); @@ -368,11 +368,11 @@ mod tests { ]); app.data.sonarr_data.series.select_index(Some(1)); app.data.sonarr_data.delete_series_files = true; - app.data.sonarr_data.add_list_exclusion = true; + app.data.sonarr_data.add_list_exclusion = false; let expected_delete_series_params = DeleteSeriesParams { id: 1, delete_series_files: true, - add_list_exclusion: true, + add_list_exclusion: false, }; let delete_series_params = DeleteSeriesHandler::new( From 12dbbf0b1b91be7c4ae0806106482553230403a3 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 01:10:41 -0600 Subject: [PATCH 124/170] test: give the statistics fixtures distinct file and total counts The artist, album and season statistics fixtures set the file count equal to the total count, so the columns rendering the pair showed the same number twice and swapping the two fields changed nothing. The JSON fixtures move with them so the round trip assertions still hold. --- src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs | 2 +- .../blocklist/lidarr_blocklist_network_tests.rs | 8 ++++---- .../library/albums/lidarr_albums_network_tests.rs | 2 +- src/network/lidarr_network/lidarr_network_test_utils.rs | 8 ++++---- src/network/sonarr_network/sonarr_network_test_utils.rs | 4 ++-- ...s__snapshot_tests__artist_details_ArtistDetails_0.snap | 4 ++-- ...shot_tests__artist_details_ArtistHistoryDetails_1.snap | 2 +- ...t_tests__artist_details_ArtistHistorySortPrompt_1.snap | 2 +- ...s__snapshot_tests__artist_details_ArtistHistory_1.snap | 2 +- ..._artist_details_AutomaticallySearchArtistPrompt_0.snap | 2 +- ..._artist_details_AutomaticallySearchArtistPrompt_1.snap | 2 +- ..._artist_details_AutomaticallySearchArtistPrompt_2.snap | 2 +- ..._tests__artist_details_FilterArtistHistoryError_1.snap | 2 +- ...pshot_tests__artist_details_FilterArtistHistory_1.snap | 2 +- ..._artist_details_ManualArtistSearchConfirmPrompt_2.snap | 2 +- ...ts__artist_details_ManualArtistSearchSortPrompt_2.snap | 2 +- ...apshot_tests__artist_details_ManualArtistSearch_2.snap | 2 +- ...napshot_tests__artist_details_SearchAlbumsError_0.snap | 4 ++-- ...ts__snapshot_tests__artist_details_SearchAlbums_0.snap | 4 ++-- ..._tests__artist_details_SearchArtistHistoryError_1.snap | 2 +- ...pshot_tests__artist_details_SearchArtistHistory_1.snap | 2 +- ...tests__artist_details_UpdateAndScanArtistPrompt_0.snap | 2 +- ...tests__artist_details_UpdateAndScanArtistPrompt_1.snap | 2 +- ...tests__artist_details_UpdateAndScanArtistPrompt_2.snap | 2 +- ...ls_ui_renders_artist_overview_over_artist_details.snap | 2 +- ...nders_automatic_search_prompt_over_artist_details.snap | 2 +- ...i_renders_delete_album_prompt_over_artist_details.snap | 2 +- ...enders_update_and_scan_prompt_over_artist_details.snap | 2 +- ...napshot_tests__empty_artist_details_ArtistDetails.snap | 2 +- ...napshot_tests__empty_artist_details_ArtistHistory.snap | 2 +- ..._tests__empty_artist_details_ArtistHistoryDetails.snap | 2 +- ...ot_tests__empty_artist_details_ManualArtistSearch.snap | 2 +- ...pshot_tests__loading_artist_details_ArtistDetails.snap | 2 +- ...pshot_tests__loading_artist_details_ArtistHistory.snap | 2 +- ..._tests__loading_artist_details_ManualArtistSearch.snap | 2 +- ...hot_tests__edit_artist_renders_over_ArtistDetails.snap | 4 ++-- ...hot_tests__edit_artist_renders_over_ArtistHistory.snap | 4 ++-- ...ests__edit_artist_renders_over_ManualArtistSearch.snap | 4 ++-- ...s__library_ui_renders_artist_details_over_library.snap | 6 +++--- ...ts__library_ui_renders_delete_artist_over_library.snap | 2 +- ...ers_dropdown_over_edit_artist_over_artist_details.snap | 2 +- ...sts__library_ui_renders_update_all_artists_prompt.snap | 2 +- ...ts__tests__snapshot_tests__lidarr_library_Artists.snap | 2 +- ..._snapshot_tests__lidarr_library_ArtistsSortPrompt.snap | 2 +- ...sts__snapshot_tests__lidarr_library_FilterArtists.snap | 2 +- ...snapshot_tests__lidarr_library_FilterArtistsError.snap | 2 +- ...sts__snapshot_tests__lidarr_library_SearchArtists.snap | 2 +- ...snapshot_tests__lidarr_library_SearchArtistsError.snap | 2 +- ...tests__tests__snapshot_tests__lidarr_tabs_Artists.snap | 2 +- ...ts__snapshot_tests__lidarr_ui_renders_library_tab.snap | 2 +- ...t_tests__lidarr_ui_renders_library_tab_with_error.snap | 2 +- ...sts__lidarr_ui_renders_notification_success_popup.snap | 2 +- ...ts__library_ui_renders_series_details_over_series.snap | 2 +- ...shot_tests__series_details_ui_SearchSeasonError_0.snap | 2 +- ..._snapshot_tests__series_details_ui_SearchSeason_0.snap | 2 +- ...snapshot_tests__series_details_ui_SeriesDetails_0.snap | 2 +- 56 files changed, 71 insertions(+), 71 deletions(-) diff --git a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs index fe433443..363079d8 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs @@ -338,7 +338,7 @@ pub(in crate::handlers::sonarr_handlers) mod utils { DateTime::parse_from_rfc3339("2022-10-24T01:00:00Z").unwrap(), )), next_airing: None, - episode_file_count: 10, + episode_file_count: 7, episode_count: 10, total_episode_count: 10, size_on_disk: 36708563419, diff --git a/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs index 23aab6de..ff8392ab 100644 --- a/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs +++ b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs @@ -112,7 +112,7 @@ mod tests { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, @@ -151,7 +151,7 @@ mod tests { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, @@ -263,7 +263,7 @@ mod tests { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, @@ -301,7 +301,7 @@ mod tests { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, diff --git a/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs b/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs index 5955ae65..4023e4f5 100644 --- a/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs +++ b/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs @@ -31,7 +31,7 @@ mod tests { "ratings": {"votes": 15, "value": 8.4}, "releaseDate": "2023-01-01T00:00:00Z", "statistics": { - "trackFileCount": 10, + "trackFileCount": 8, "trackCount": 10, "totalTrackCount": 10, "sizeOnDisk": 1024, diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index 42e673eb..325292a5 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -50,7 +50,7 @@ pub mod test_utils { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, @@ -71,7 +71,7 @@ pub mod test_utils { "ratings": {"votes": 15, "value": 8.4}, "releaseDate": "2023-01-01T00:00:00Z", "statistics": { - "trackFileCount": 10, + "trackFileCount": 8, "trackCount": 10, "totalTrackCount": 10, "sizeOnDisk": 1024, @@ -96,7 +96,7 @@ pub mod test_utils { pub fn artist_statistics() -> ArtistStatistics { ArtistStatistics { album_count: 1, - track_file_count: 15, + track_file_count: 12, track_count: 15, total_track_count: 15, size_on_disk: 12345, @@ -247,7 +247,7 @@ pub mod test_utils { pub fn album_statistics() -> AlbumStatistics { AlbumStatistics { - track_file_count: 10, + track_file_count: 8, track_count: 10, total_track_count: 10, size_on_disk: 1024, diff --git a/src/network/sonarr_network/sonarr_network_test_utils.rs b/src/network/sonarr_network/sonarr_network_test_utils.rs index ac60fa57..56fe1c65 100644 --- a/src/network/sonarr_network/sonarr_network_test_utils.rs +++ b/src/network/sonarr_network/sonarr_network_test_utils.rs @@ -28,7 +28,7 @@ pub mod test_utils { "monitored": true, "statistics": { "previousAiring": "2022-10-24T01:00:00Z", - "episodeFileCount": 10, + "episodeFileCount": 7, "episodeCount": 10, "totalEpisodeCount": 10, "sizeOnDisk": 36708563419, @@ -306,7 +306,7 @@ pub mod test_utils { DateTime::parse_from_rfc3339("2022-10-24T01:00:00Z").unwrap(), )), next_airing: None, - episode_file_count: 10, + episode_file_count: 7, episode_count: 10, total_episode_count: 10, size_on_disk: 36708563419, diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap index 1f04b0d5..7f55058e 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap @@ -19,12 +19,12 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap index c4f8c402..7d1f8915 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ - │Tracks: 15/15 │Source Title: Test source title │ │ + │Tracks: 12/15 │Source Title: Test source title │ │ │╭ Artist Details ─────────────│Event Type: grabbed │───────────────────────────────╮│ ││ Albums │ History │ Manual Sear│Quality: Lossless │ ││ ││───────────────────────────────│Date: 2023-01-01 00:00:00 UTC │───────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap index 3bca44cb..bf47dc25 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap index 298827c7..38d54b6b 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap index 53cf5840..184348e5 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Tracks: 15/15 │Do you want to trigger an automatic search of your indexers│ │ + │Tracks: 12/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap index 88ba11f2..7a8a78c4 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Tracks: 15/15 │Do you want to trigger an automatic search of your indexers│ │ + │Tracks: 12/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap index a99b6d48..11f3ce3f 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Tracks: 15/15 │Do you want to trigger an automatic search of your indexers│ │ + │Tracks: 12/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap index b53ec9e0..c88df56f 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap index 98620f9a..8e3d0176 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap index a1239a69..888c4313 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes ╭───────────────── Download Rejected Release ──────────────────╮ │ │Albums: 1 │ Do you really want to download the rejected release: Test │ │ - │Tracks: 15/15 │ Release? │ │ + │Tracks: 12/15 │ Release? │ │ │╭ Artist Details ──────────────────────│ │───────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││────────────────────────────────────────│Rejection reasons: │───────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap index 85425a7a..ca032102 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap index e17c8acf..9311ad07 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap index a710ffe3..fe95a76c 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap @@ -19,12 +19,12 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap index 6e179e3f..e3910b8d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap @@ -19,12 +19,12 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap index b9ae5e1d..4c4b6100 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap index db7de871..210253cf 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap index 8ba046a1..65e849ae 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Tracks: 15/15 │ Do you want to trigger an update and disk scan for the │ │ + │Tracks: 12/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap index 1d772726..dc0a1b2d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Tracks: 15/15 │ Do you want to trigger an update and disk scan for the │ │ + │Tracks: 12/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap index 91627206..8756a3a3 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Tracks: 15/15 │ Do you want to trigger an update and disk scan for the │ │ + │Tracks: 12/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap index 61d7a007..f9ecb198 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap @@ -19,7 +19,7 @@ expression: output │Metadata Pr│ │ │ │Monitored: │ │ │ │Albums: 1 │ │ │ - │Tracks: 15/│ │ │ + │Tracks: 12/│ │ │ │╭ Artist D│ │───────────╮│ ││ Albums │ │ │ ││ ││──────────│ │───────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap index 53cf5840..184348e5 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Tracks: 15/15 │Do you want to trigger an automatic search of your indexers│ │ + │Tracks: 12/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap index 00159b88..9568b760 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭───────────────────── Delete Album ──────────────────────╮ │ - │Tracks: 15/15 │ Do you really want to delete the album: │ │ + │Tracks: 12/15 │ Do you really want to delete the album: │ │ │╭ Artist Details ───────────────────────│ Test Album? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap index 8ba046a1..65e849ae 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Tracks: 15/15 │ Do you want to trigger an update and disk scan for the │ │ + │Tracks: 12/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap index ac41b703..8467d379 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap index 298827c7..38d54b6b 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap index c4f8c402..7d1f8915 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ - │Tracks: 15/15 │Source Title: Test source title │ │ + │Tracks: 12/15 │Source Title: Test source title │ │ │╭ Artist Details ─────────────│Event Type: grabbed │───────────────────────────────╮│ ││ Albums │ History │ Manual Sear│Quality: Lossless │ ││ ││───────────────────────────────│Date: 2023-01-01 00:00:00 UTC │───────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap index 61c2abde..e007cbd2 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap index 61c2abde..e007cbd2 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap index 61c2abde..e007cbd2 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap index 61c2abde..e007cbd2 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap @@ -19,7 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap index ee98f911..761d72fb 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ @@ -19,7 +19,7 @@ expression: output │Metadata Profile: S│ │ │ │Monitored: Yes │ ╭───╮ │ │ │Albums: 1 │ Monitored: │ ✔ │ │ │ - │Tracks: 15/15 │ ╰───╯ │ │ + │Tracks: 12/15 │ ╰───╯ │ │ │╭ Artist Details │ ╭─────────────────────────────────────────────────╮ │────────────────────╮│ ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap index 993c4969..ed50f5f4 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ @@ -19,7 +19,7 @@ expression: output │Metadata Profile: S│ │ │ │Monitored: Yes │ ╭───╮ │ │ │Albums: 1 │ Monitored: │ ✔ │ │ │ - │Tracks: 15/15 │ ╰───╯ │ │ + │Tracks: 12/15 │ ╰───╯ │ │ │╭ Artist Details │ ╭─────────────────────────────────────────────────╮ │────────────────────╮│ ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap index 7545a8fe..bf788945 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ @@ -19,7 +19,7 @@ expression: output │Metadata Profile: S│ │ │ │Monitored: Yes │ ╭───╮ │ │ │Albums: 1 │ Monitored: │ ✔ │ │ │ - │Tracks: 15/15 │ ╰───╯ │ │ + │Tracks: 12/15 │ ╰───╯ │ │ │╭ Artist Details │ ╭─────────────────────────────────────────────────╮ │────────────────────╮│ ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap index 5ff3575d..cd425652 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ @@ -19,12 +19,12 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap index c4602f8d..ee7c69bf 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap index 311a0530..1955204e 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex 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 index d20a2fa1..83c5301c 100644 --- 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 @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap index 1443a4fb..45fd1574 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap @@ -4,4 +4,4 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap index c2d2087b..1c960b3f 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap index 24b5f600..fea1362e 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap index 2b5b359b..f02c2d86 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap index 1c21242e..2bfaefbb 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap index 6af1685a..0d39a1c7 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap index 9ca7db1a..bf284634 100644 --- a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap @@ -6,7 +6,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap index e7ffe6b2..42749d4e 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap index 1b2b382b..3e81a80f 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap @@ -22,7 +22,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap index cb483c72..cec6243f 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap index 86812c78..d638426d 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap @@ -26,7 +26,7 @@ expression: output ││ Seasons │ History ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Season Episode Count Size on Disk ││ - ││=> 🏷 Season title 10/10 34.19 GB ││ + ││=> 🏷 Season title 7/10 34.19 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap index 90c6bb9a..94c4e395 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap @@ -26,7 +26,7 @@ expression: output ││ Seasons │ History ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Season Episode Count Size on Disk ││ - ││=> 🏷 Season title 10/10 34.19 GB ││ + ││=> 🏷 Season title 7/10 34.19 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap index d9d22377..4b9eb076 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap @@ -26,7 +26,7 @@ expression: output ││ Seasons │ History ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Season Episode Count Size on Disk ││ - ││=> 🏷 Season title 10/10 34.19 GB ││ + ││=> 🏷 Season title 7/10 34.19 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap index 6d92d0e1..8139a786 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap @@ -26,7 +26,7 @@ expression: output ││ Seasons │ History ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Season Episode Count Size on Disk ││ - ││=> 🏷 Season title 10/10 34.19 GB ││ + ││=> 🏷 Season title 7/10 34.19 GB ││ ││ ││ ││ ││ ││ ││ From e79cc7a12d693bba13e9363ecb3812a5f19fafdc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 12:35:56 -0600 Subject: [PATCH 125/170] fix(radarr): show a loading block while the updates popup refreshes The updates popup only checked whether its text was empty, so during a refresh it kept rendering the previously fetched text. Opening the system updates view dispatches a request for the update notes, so the popup could sit on stale content until the response landed. Guard on the loading flag as well, matching the sonarr, lidarr and readarr updates popups, and add the loading-state test those trees have so both halves of the condition are exercised. The pre-existing empty-updates test never reaches the second half on its own. --- ...p_is_loading_when_updates_are_present.snap | 48 +++++++++++++++++++ src/ui/radarr_ui/system/system_details_ui.rs | 2 +- .../system/system_details_ui_tests.rs | 15 ++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap diff --git a/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap b/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap new file mode 100644 index 00000000..c8e5896f --- /dev/null +++ b/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/system/system_details_ui.rs b/src/ui/radarr_ui/system/system_details_ui.rs index bc8db27f..465677b7 100644 --- a/src/ui/radarr_ui/system/system_details_ui.rs +++ b/src/ui/radarr_ui/system/system_details_ui.rs @@ -126,7 +126,7 @@ fn draw_updates_popup(f: &mut Frame<'_>, app: &mut App<'_>) { let updates = app.data.radarr_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.radarr_data.updates.offset, 0)); diff --git a/src/ui/radarr_ui/system/system_details_ui_tests.rs b/src/ui/radarr_ui/system/system_details_ui_tests.rs index 77f5ef37..2ff9f596 100644 --- a/src/ui/radarr_ui/system/system_details_ui_tests.rs +++ b/src/ui/radarr_ui/system/system_details_ui_tests.rs @@ -87,6 +87,21 @@ mod tests { insta::assert_snapshot!(output); } + #[test] + fn test_system_details_ui_updates_popup_is_loading_when_updates_are_present() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveRadarrBlock::SystemUpdates.into()); + + assert!(!app.data.radarr_data.updates.get_text().is_empty()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_system_details_ui_renders_updates_loading_when_empty() { let mut app = App::test_default_fully_populated(); From c525fb9a4e4dc16d358bf8ecb43ccdb9710afef6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 12:40:59 -0600 Subject: [PATCH 126/170] feat(radarr): render the movie overview popup at the large size The movie overview was the only overview popup drawn at the small size; the sonarr, lidarr and readarr overview popups all use the large size. The small size wrapped longer overviews sooner and gave the text roughly a quarter of the space the other trees allow. Draw it at the large size to match them. The wider popup now covers the collection details behind it, as the other overview popups do. --- .../collections/collection_details_ui.rs | 2 +- ...ts__snapshot_tests__ViewMovieOverview.snap | 54 +++++++++---------- ...ils_ui_renders_movie_overview_loading.snap | 54 +++++++++---------- ...vie_overview_when_the_modal_is_absent.snap | 54 +++++++++---------- ...ls_ui_renders_scrolled_movie_overview.snap | 54 +++++++++---------- 5 files changed, 109 insertions(+), 109 deletions(-) diff --git a/src/ui/radarr_ui/collections/collection_details_ui.rs b/src/ui/radarr_ui/collections/collection_details_ui.rs index 250b88ab..5713a9b2 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui.rs @@ -46,7 +46,7 @@ impl DrawUi for CollectionDetailsUi { draw_popup(f, app, draw_collection_details, Size::Large); if context_option.unwrap_or(active_radarr_block) == ActiveRadarrBlock::ViewMovieOverview { - draw_popup(f, app, draw_movie_overview, Size::Small); + draw_popup(f, app, draw_movie_overview, Size::Large); } } } diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap index 25dfa99e..0ff98b61 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap @@ -8,35 +8,35 @@ expression: output - ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │Overview: Collection blah blah blah │ - │Root Folder Path: /nfs/movies │ - │Quality Profile: HD - 1080p │ - │Minimum Availability: Released │ - │Monitored: Yes │ - │Search on Add: Yes │ + ╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Collection movie blah blah blah │ + │ │ + │It was shot in Madison, Wisconsin: a city the cast never really left. │ + │ │ + │The sequel took seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ │ │ │ │ - │ ╭ Overview ────────────────────────────────────────────────────╮ │ - │ Movies ───────────────────│Collection movie blah blah blah │───────────────────────────│ - │ ✔ Title │ │ │ - │=> ✔ Test │It was shot in Madison, Wisconsin: a city the cast never really │family, fun │ - │ │left. │ │ - │ │ │ │ - │ │The sequel took seven years to finish. │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ ╰────────────────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap index f2d2490d..e3a3c612 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap @@ -8,35 +8,35 @@ expression: output - ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │Overview: Collection blah blah blah │ - │Root Folder Path: /nfs/movies │ - │Quality Profile: HD - 1080p │ - │Minimum Availability: Released │ - │Monitored: Yes │ - │Search on Add: Yes │ + ╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ │ │ │ │ - │ ╭ Overview ────────────────────────────────────────────────────╮ │ - │ Movies ───────────────────│ │───────────────────────────│ - │ │ │ │ - │ │ Loading ... │ │ - │ Loading ... │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ ╰────────────────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap index 474665f3..e3a3c612 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap @@ -8,35 +8,35 @@ expression: output - ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │Overview: Collection blah blah blah │ - │Root Folder Path: /nfs/movies │ - │Quality Profile: HD - 1080p │ - │Minimum Availability: Released │ - │Monitored: Yes │ - │Search on Add: Yes │ + ╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ │ │ │ │ - │ ╭ Overview ────────────────────────────────────────────────────╮ │ - │ Movies ───────────────────│ │───────────────────────────│ - │ ✔ Title │ │ │ - │=> ✔ Test │ Loading ... │family, fun │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ ╰────────────────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap index b03f945a..365f8fcc 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap @@ -8,35 +8,35 @@ expression: output - ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │Overview: Collection blah blah blah │ - │Root Folder Path: /nfs/movies │ - │Quality Profile: HD - 1080p │ - │Minimum Availability: Released │ - │Monitored: Yes │ - │Search on Add: Yes │ + ╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │It was shot in Madison, Wisconsin: a city the cast never really left. │ + │ │ + │The sequel took seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ │ │ │ │ - │ ╭ Overview ────────────────────────────────────────────────────╮ │ - │ Movies ───────────────────│It was shot in Madison, Wisconsin: a city the cast never really │───────────────────────────│ - │ ✔ Title │left. │ │ - │=> ✔ Test │ │family, fun │ - │ │The sequel took seven years to finish. │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ ╰────────────────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ From 65ba37c09af93fc13da29e3dfcfe4ed671c1f81d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 13:06:04 -0600 Subject: [PATCH 127/170] fix: parse the log level from the unscrolled log text draw_logs read the level from the log line's Display output, which drops the first offset characters. Once a log line is scrolled, the field at index 1 is no longer the level: past the timestamp it yields the logger name and colours the row wrong, and past the final delimiter the index is out of bounds and panics, taking the TUI down. Parse log.text instead, matching what draw_logs_popup already does. The displayed text is unchanged. --- src/ui/lidarr_ui/system/mod.rs | 2 +- src/ui/lidarr_ui/system/system_ui_tests.rs | 77 +++++++++++++++++++++ src/ui/radarr_ui/system/mod.rs | 2 +- src/ui/radarr_ui/system/system_ui_tests.rs | 77 +++++++++++++++++++++ src/ui/readarr_ui/system/mod.rs | 2 +- src/ui/readarr_ui/system/system_ui_tests.rs | 69 ++++++++++++++++++ src/ui/sonarr_ui/system/mod.rs | 2 +- src/ui/sonarr_ui/system/system_ui_tests.rs | 77 +++++++++++++++++++++ 8 files changed, 304 insertions(+), 4 deletions(-) diff --git a/src/ui/lidarr_ui/system/mod.rs b/src/ui/lidarr_ui/system/mod.rs index e34362d9..56da00c9 100644 --- a/src/ui/lidarr_ui/system/mod.rs +++ b/src/ui/lidarr_ui/system/mod.rs @@ -165,7 +165,7 @@ fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let logs_box = SelectableList::new(&mut app.data.lidarr_data.logs, |log| { let log_line = log.to_string(); - let level = log_line.split('|').collect::>()[1].to_string(); + let level = log.text.split('|').collect::>()[1].to_string(); style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) }) diff --git a/src/ui/lidarr_ui/system/system_ui_tests.rs b/src/ui/lidarr_ui/system/system_ui_tests.rs index 58d9fcf7..b6fd236d 100644 --- a/src/ui/lidarr_ui/system/system_ui_tests.rs +++ b/src/ui/lidarr_ui/system/system_ui_tests.rs @@ -25,6 +25,83 @@ mod tests { }); } + mod test_log_level_styling { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + use ratatui::style::{Modifier, Style}; + + use crate::models::HorizontallyScrollableText; + use crate::ui::styles::failure_style; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_system_ui_styles_a_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::System.into()); + app.data.lidarr_data.logs.set_items(log_lines_vec()); + app.data.lidarr_data.logs.items[1] + .offset + .store(30, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "CommandExecutor|log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_system_ui_styles_a_fully_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::System.into()); + app.data.lidarr_data.logs.set_items(log_lines_vec()); + app.data.lidarr_data.logs.items[1] + .offset + .store(46, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SystemUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } + } + mod snapshot_tests { use crate::models::stateful_list::StatefulList; use crate::ui::ui_test_utils::test_utils::TerminalSize; diff --git a/src/ui/radarr_ui/system/mod.rs b/src/ui/radarr_ui/system/mod.rs index 8bec01e2..270286fa 100644 --- a/src/ui/radarr_ui/system/mod.rs +++ b/src/ui/radarr_ui/system/mod.rs @@ -172,7 +172,7 @@ fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let logs_box = SelectableList::new(&mut app.data.radarr_data.logs, |log| { let log_line = log.to_string(); - let level = log_line.split('|').collect::>()[1].to_string(); + let level = log.text.split('|').collect::>()[1].to_string(); style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) }) diff --git a/src/ui/radarr_ui/system/system_ui_tests.rs b/src/ui/radarr_ui/system/system_ui_tests.rs index 31bd1610..fec72e7a 100644 --- a/src/ui/radarr_ui/system/system_ui_tests.rs +++ b/src/ui/radarr_ui/system/system_ui_tests.rs @@ -25,6 +25,83 @@ mod tests { }); } + mod test_log_level_styling { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + use ratatui::style::{Modifier, Style}; + + use crate::models::HorizontallyScrollableText; + use crate::ui::styles::failure_style; + use crate::ui::ui_test_utils::test_utils::create_test_terminal; + + use super::*; + + #[test] + fn test_system_ui_styles_a_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::System.into()); + app.data.radarr_data.logs.set_items(log_lines_vec()); + app.data.radarr_data.logs.items[1] + .offset + .store(30, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "CommandExecutor|log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_system_ui_styles_a_fully_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::System.into()); + app.data.radarr_data.logs.set_items(log_lines_vec()); + app.data.radarr_data.logs.items[1] + .offset + .store(46, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SystemUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-15 16:14:45 UTC|INFO|DownloadDecisionMaker|log line alpha".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } + } + mod snapshot_tests { use super::*; use crate::models::stateful_list::StatefulList; diff --git a/src/ui/readarr_ui/system/mod.rs b/src/ui/readarr_ui/system/mod.rs index 2c4f3059..d353adae 100644 --- a/src/ui/readarr_ui/system/mod.rs +++ b/src/ui/readarr_ui/system/mod.rs @@ -161,7 +161,7 @@ fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let logs_box = SelectableList::new(&mut app.data.readarr_data.logs, |log| { let log_line = log.to_string(); - let level = extract_log_level(&log_line); + let level = extract_log_level(&log.text); style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) }) diff --git a/src/ui/readarr_ui/system/system_ui_tests.rs b/src/ui/readarr_ui/system/system_ui_tests.rs index 4716ac80..8aa54a35 100644 --- a/src/ui/readarr_ui/system/system_ui_tests.rs +++ b/src/ui/readarr_ui/system/system_ui_tests.rs @@ -63,6 +63,75 @@ mod tests { } } + mod test_log_level_styling { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + use ratatui::style::{Modifier, Style}; + + use crate::ui::styles::failure_style; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_system_ui_styles_a_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.logs.set_items(log_lines_vec()); + app.data.readarr_data.logs.items[2] + .offset + .store(30, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "CommandExecutor|log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_system_ui_styles_a_fully_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.logs.set_items(log_lines_vec()); + app.data.readarr_data.logs.items[2] + .offset + .store(46, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SystemUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + } + mod snapshot_tests { use crate::models::stateful_list::StatefulList; use crate::ui::ui_test_utils::test_utils::TerminalSize; diff --git a/src/ui/sonarr_ui/system/mod.rs b/src/ui/sonarr_ui/system/mod.rs index 233acd7f..5e51a301 100644 --- a/src/ui/sonarr_ui/system/mod.rs +++ b/src/ui/sonarr_ui/system/mod.rs @@ -165,7 +165,7 @@ fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { 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(); + let level = log.text.split('|').collect::>()[1].to_string(); style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) }) diff --git a/src/ui/sonarr_ui/system/system_ui_tests.rs b/src/ui/sonarr_ui/system/system_ui_tests.rs index 39b258c1..97d2d6e0 100644 --- a/src/ui/sonarr_ui/system/system_ui_tests.rs +++ b/src/ui/sonarr_ui/system/system_ui_tests.rs @@ -25,6 +25,83 @@ mod tests { }); } + mod test_log_level_styling { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + use ratatui::style::{Modifier, Style}; + + use crate::models::HorizontallyScrollableText; + use crate::ui::styles::failure_style; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_system_ui_styles_a_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(log_lines_vec()); + app.data.sonarr_data.logs.items[1] + .offset + .store(30, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "CommandExecutor|log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_system_ui_styles_a_fully_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(log_lines_vec()); + app.data.sonarr_data.logs.items[1] + .offset + .store(46, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SystemUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } + } + mod snapshot_tests { use crate::models::stateful_list::StatefulList; use crate::ui::ui_test_utils::test_utils::TerminalSize; From caafe1602e46f8af782d4f6b8a71f800399b9614 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 13:07:54 -0600 Subject: [PATCH 128/170] test(radarr): name the updates loading test after its neighbours The Radarr system details tests prefix their loading cases with renders_. This one was copied verbatim from the other trees and did not match. Rename it and its snapshot to fit, leaving the sibling trees alone. --- ...ls_ui_renders_updates_loading_when_updates_are_present.snap} | 0 src/ui/radarr_ui/system/system_details_ui_tests.rs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/ui/radarr_ui/system/snapshots/{managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap => managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_updates_are_present.snap} (100%) diff --git a/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap b/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_updates_are_present.snap similarity index 100% rename from src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap rename to src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_updates_are_present.snap diff --git a/src/ui/radarr_ui/system/system_details_ui_tests.rs b/src/ui/radarr_ui/system/system_details_ui_tests.rs index 2ff9f596..94891b8c 100644 --- a/src/ui/radarr_ui/system/system_details_ui_tests.rs +++ b/src/ui/radarr_ui/system/system_details_ui_tests.rs @@ -88,7 +88,7 @@ mod tests { } #[test] - fn test_system_details_ui_updates_popup_is_loading_when_updates_are_present() { + fn test_system_details_ui_renders_updates_loading_when_updates_are_present() { let mut app = App::test_default_fully_populated(); app.is_loading = true; app.push_navigation_stack(ActiveRadarrBlock::SystemUpdates.into()); From 243b22a426dbc56895c6ee38fbb217bc6b511064 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 13:27:02 -0600 Subject: [PATCH 129/170] fix: keep a space between overview words that span a line break The collapsed one-line overview on the Lidarr artist, Sonarr series, and Readarr author details views replaced every line break and tab with an empty string. Words on either side of a paragraph break fused together, rendering as "20th century.She was born in Ohio". Collapse each run of whitespace to a single space instead, and trim the result. Handling a whole run at a time keeps a Windows line ending, an indented paragraph, or a space that already precedes a line break from producing two spaces. The overview popups are unaffected: they receive the raw text and keep their paragraph breaks. --- .../servarr_data/readarr/readarr_data.rs | 2 +- .../lidarr_blocklist_network_tests.rs | 8 +-- .../lidarr_network_test_utils.rs | 2 +- .../sonarr_network_test_utils.rs | 4 +- src/ui/lidarr_ui/library/artist_details_ui.rs | 22 +++---- ...tests__artist_details_ArtistDetails_0.snap | 2 +- ...artist_details_ArtistHistoryDetails_1.snap | 2 +- ...ist_details_ArtistHistorySortPrompt_1.snap | 2 +- ...tests__artist_details_ArtistHistory_1.snap | 2 +- ...ils_AutomaticallySearchArtistPrompt_0.snap | 2 +- ...ils_AutomaticallySearchArtistPrompt_1.snap | 2 +- ...ils_AutomaticallySearchArtistPrompt_2.snap | 2 +- ...st_details_FilterArtistHistoryError_1.snap | 2 +- ..._artist_details_FilterArtistHistory_1.snap | 2 +- ...ils_ManualArtistSearchConfirmPrompt_2.snap | 2 +- ...etails_ManualArtistSearchSortPrompt_2.snap | 2 +- ...__artist_details_ManualArtistSearch_2.snap | 2 +- ...s__artist_details_SearchAlbumsError_0.snap | 2 +- ..._tests__artist_details_SearchAlbums_0.snap | 2 +- ...st_details_SearchArtistHistoryError_1.snap | 2 +- ..._artist_details_SearchArtistHistory_1.snap | 2 +- ...t_details_UpdateAndScanArtistPrompt_0.snap | 2 +- ...t_details_UpdateAndScanArtistPrompt_1.snap | 2 +- ...t_details_UpdateAndScanArtistPrompt_2.snap | 2 +- ...s_artist_overview_over_artist_details.snap | 2 +- ...tic_search_prompt_over_artist_details.snap | 2 +- ...lete_album_prompt_over_artist_details.snap | 2 +- ...e_and_scan_prompt_over_artist_details.snap | 2 +- ...s__empty_artist_details_ArtistDetails.snap | 2 +- ...s__empty_artist_details_ArtistHistory.snap | 2 +- ...y_artist_details_ArtistHistoryDetails.snap | 2 +- ...pty_artist_details_ManualArtistSearch.snap | 2 +- ..._loading_artist_details_ArtistDetails.snap | 2 +- ..._loading_artist_details_ArtistHistory.snap | 2 +- ...ing_artist_details_ManualArtistSearch.snap | 2 +- ...dit_artist_renders_over_ArtistDetails.snap | 2 +- ...dit_artist_renders_over_ArtistHistory.snap | 2 +- ...rtist_renders_over_ManualArtistSearch.snap | 2 +- ...i_renders_artist_details_over_library.snap | 2 +- .../readarr_ui/library/author_details_ui.rs | 22 +++---- ...tests__author_details_AuthorDetails_0.snap | 2 +- ...author_details_AuthorHistoryDetails_1.snap | 2 +- ...hor_details_AuthorHistorySortPrompt_1.snap | 2 +- ...tests__author_details_AuthorHistory_1.snap | 2 +- ...ils_AutomaticallySearchAuthorPrompt_0.snap | 2 +- ...ils_AutomaticallySearchAuthorPrompt_1.snap | 2 +- ...ils_AutomaticallySearchAuthorPrompt_2.snap | 2 +- ...or_details_FilterAuthorHistoryError_1.snap | 2 +- ..._author_details_FilterAuthorHistory_1.snap | 2 +- ...ils_ManualAuthorSearchConfirmPrompt_2.snap | 2 +- ...etails_ManualAuthorSearchSortPrompt_2.snap | 2 +- ...__author_details_ManualAuthorSearch_2.snap | 2 +- ...or_details_SearchAuthorHistoryError_1.snap | 2 +- ..._author_details_SearchAuthorHistory_1.snap | 2 +- ...ts__author_details_SearchBooksError_0.snap | 2 +- ...t_tests__author_details_SearchBooks_0.snap | 2 +- ...r_details_UpdateAndScanAuthorPrompt_0.snap | 2 +- ...r_details_UpdateAndScanAuthorPrompt_1.snap | 2 +- ...r_details_UpdateAndScanAuthorPrompt_2.snap | 2 +- ...s_author_overview_over_author_details.snap | 2 +- ...tic_search_prompt_over_author_details.snap | 2 +- ...ders_book_details_over_author_details.snap | 2 +- ...nders_delete_book_over_author_details.snap | 2 +- ..._author_details_ui_renders_statistics.snap | 2 +- ...e_and_scan_prompt_over_author_details.snap | 2 +- ...s__empty_author_details_AuthorDetails.snap | 2 +- ...s__empty_author_details_AuthorHistory.snap | 2 +- ...y_author_details_AuthorHistoryDetails.snap | 2 +- ...pty_author_details_ManualAuthorSearch.snap | 2 +- ..._loading_author_details_AuthorDetails.snap | 2 +- ..._loading_author_details_AuthorHistory.snap | 2 +- ...ing_author_details_ManualAuthorSearch.snap | 2 +- ...author_ui_renders_over_author_details.snap | 2 +- ...i_renders_author_details_over_library.snap | 2 +- src/ui/sonarr_ui/library/series_details_ui.rs | 22 +++---- ...__edit_series_EditSeriesConfirmPrompt.snap | 2 +- ...EditSeriesConfirmPrompt_SeriesDetails.snap | 4 +- ...t_tests__edit_series_EditSeriesPrompt.snap | 2 +- ...series_EditSeriesPrompt_SeriesDetails.snap | 4 +- ...eries_EditSeriesSelectLanguageProfile.snap | 2 +- ...esSelectLanguageProfile_SeriesDetails.snap | 4 +- ...series_EditSeriesSelectQualityProfile.snap | 2 +- ...iesSelectQualityProfile_SeriesDetails.snap | 4 +- ...dit_series_EditSeriesSelectSeriesType.snap | 2 +- ...tSeriesSelectSeriesType_SeriesDetails.snap | 4 +- ...ry_ui_renders_edit_series_over_series.snap | 2 +- ...ui_renders_series_details_over_series.snap | 2 +- ...s__empty_series_details_SeriesDetails.snap | 2 +- ...s__empty_series_details_SeriesHistory.snap | 2 +- ...y_series_details_SeriesHistoryDetails.snap | 2 +- ..._loading_series_details_SeriesDetails.snap | 2 +- ..._loading_series_details_SeriesHistory.snap | 2 +- ..._ui_AutomaticallySearchSeriesPrompt_0.snap | 2 +- ..._ui_AutomaticallySearchSeriesPrompt_1.snap | 2 +- ...details_ui_FilterSeriesHistoryError_1.snap | 2 +- ...ries_details_ui_FilterSeriesHistory_1.snap | 2 +- ...series_details_ui_SearchSeasonError_0.snap | 2 +- ...sts__series_details_ui_SearchSeason_0.snap | 2 +- ...details_ui_SearchSeriesHistoryError_1.snap | 2 +- ...ries_details_ui_SearchSeriesHistory_1.snap | 2 +- ...ts__series_details_ui_SeriesDetails_0.snap | 2 +- ...ies_details_ui_SeriesHistoryDetails_1.snap | 2 +- ..._details_ui_SeriesHistorySortPrompt_1.snap | 2 +- ...ts__series_details_ui_SeriesHistory_1.snap | 2 +- ...etails_ui_UpdateAndScanSeriesPrompt_0.snap | 2 +- ...etails_ui_UpdateAndScanSeriesPrompt_1.snap | 2 +- ...s_series_overview_over_series_details.snap | 2 +- src/ui/utils.rs | 10 +++ src/ui/utils_tests.rs | 62 ++++++++++++++++++- 109 files changed, 206 insertions(+), 158 deletions(-) diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index d8cb5f26..e5dbcb38 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -269,7 +269,7 @@ impl ReadarrData<'_> { overview: Some( "some interesting description of the author\r\n\ \r\n\ - He was born in Madison, Wisconsin: a city he has never really left.\r\n\ + \tHe was born in Madison, Wisconsin: a city he has never really left. \r\n\ \r\n\ His first novel took him seven years to finish.\r\n" .to_owned(), diff --git a/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs index ff8392ab..cae0605c 100644 --- a/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs +++ b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs @@ -97,7 +97,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist\r\n\r\nShe was born in Madison, Wisconsin: a city she has never really left.\r\n\r\nHer first album took her seven years to finish.\r\n", + "overview": "some interesting description of the artist\r\n\r\n\tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -136,7 +136,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist\r\n\r\nShe was born in Madison, Wisconsin: a city she has never really left.\r\n\r\nHer first album took her seven years to finish.\r\n", + "overview": "some interesting description of the artist\r\n\r\n\tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -248,7 +248,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist\r\n\r\nShe was born in Madison, Wisconsin: a city she has never really left.\r\n\r\nHer first album took her seven years to finish.\r\n", + "overview": "some interesting description of the artist\r\n\r\n\tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -286,7 +286,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist\r\n\r\nShe was born in Madison, Wisconsin: a city she has never really left.\r\n\r\nHer first album took her seven years to finish.\r\n", + "overview": "some interesting description of the artist\r\n\r\n\tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index 325292a5..d50a5ed5 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -113,7 +113,7 @@ pub mod test_utils { overview: Some( "some interesting description of the artist\r\n\ \r\n\ - She was born in Madison, Wisconsin: a city she has never really left.\r\n\ + \tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\ \r\n\ Her first album took her seven years to finish.\r\n" .to_owned(), diff --git a/src/network/sonarr_network/sonarr_network_test_utils.rs b/src/network/sonarr_network/sonarr_network_test_utils.rs index 56fe1c65..1838a28a 100644 --- a/src/network/sonarr_network/sonarr_network_test_utils.rs +++ b/src/network/sonarr_network/sonarr_network_test_utils.rs @@ -19,7 +19,7 @@ pub mod test_utils { "title": "Test", "status": "continuing", "ended": false, - "overview": "Blah blah blah\r\n\r\nIt was filmed in Madison, Wisconsin: a city the crew never really left.\r\n\r\nThe first season took seven years to finish.\r\n", + "overview": "Blah blah blah\r\n\r\n\tIt was filmed in Madison, Wisconsin: a city the crew never really left. \r\n\r\nThe first season took seven years to finish.\r\n", "network": "HBO", "seasons": [ { @@ -322,7 +322,7 @@ pub mod test_utils { overview: Some( "Blah blah blah\r\n\ \r\n\ - It was filmed in Madison, Wisconsin: a city the crew never really left.\r\n\ + \tIt was filmed in Madison, Wisconsin: a city the crew never really left. \r\n\ \r\n\ The first season took seven years to finish.\r\n" .to_owned(), diff --git a/src/ui/lidarr_ui/library/artist_details_ui.rs b/src/ui/lidarr_ui/library/artist_details_ui.rs index 91e7d1ae..a0ea6167 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui.rs @@ -5,7 +5,6 @@ use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; -use regex::Regex; use crate::app::App; use crate::models::Route; @@ -18,7 +17,8 @@ use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::decorate_peer_style; use crate::ui::utils::{ - borderless_block, get_width_from_percentage, layout_block_top_border, title_block, + borderless_block, collapse_whitespace, get_width_from_percentage, layout_block_top_border, + title_block, }; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; @@ -153,18 +153,12 @@ fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .get_by_left(¤t_selection.metadata_profile_id) .cloned() .unwrap_or_default(); - let overview = Regex::new(r"[\r\n\t]") - .unwrap() - .replace_all( - &deunicode( - current_selection - .overview - .as_ref() - .unwrap_or(&String::new()), - ), - "", - ) - .to_string(); + let overview = collapse_whitespace(&deunicode( + current_selection + .overview + .as_ref() + .unwrap_or(&String::new()), + )); let mut artist_description = vec![ Line::from(vec![ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap index 7f55058e..651ed30a 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap index 7d1f8915..cae8d5c4 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap index bf47dc25..223822d6 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap index 38d54b6b..ad92d6bf 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap index 184348e5..1ff0a887 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap index 7a8a78c4..cef0f722 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap index 11f3ce3f..202d5722 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap index c88df56f..d7e5d59d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap index 8e3d0176..66b9ef80 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap index 888c4313..1a8aa658 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap index ca032102..cd61dab9 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap index 9311ad07..2b649fe3 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap index fe95a76c..756fe8e1 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap index e3910b8d..8e46d73d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap index 4c4b6100..e8ae9c74 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap index 210253cf..338d1818 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap index 65e849ae..d112a74d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap index dc0a1b2d..24279b7d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap index 8756a3a3..cb9bd135 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap index f9ecb198..d1661061 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ │ │Disambiguat│some interesting description of the artist │ │ │Type: Perso│ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap index 184348e5..1ff0a887 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap index 9568b760..2a032e8c 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap index 65e849ae..d112a74d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap index 8467d379..98c8f630 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap index 38d54b6b..ad92d6bf 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap index 7d1f8915..cae8d5c4 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap index e007cbd2..e131b101 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap index e007cbd2..e131b101 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap index e007cbd2..e131b101 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap index e007cbd2..e131b101 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap @@ -7,7 +7,7 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap index 761d72fb..4757389b 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap @@ -7,7 +7,7 @@ expression: output => Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to fini╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ │Disambiguation: Ame│ │ │ │Type: Person │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap index ed50f5f4..bfc2c4fe 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap @@ -7,7 +7,7 @@ expression: output => Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to fini╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ │Disambiguation: Ame│ │ │ │Type: Person │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap index bf788945..71401ee8 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap @@ -7,7 +7,7 @@ expression: output => Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to fini╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ │Disambiguation: Ame│ │ │ │Type: Person │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap index cd425652..28aacdbb 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap @@ -7,7 +7,7 @@ expression: output => Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artistShe was born in Madison, Wisconsin: a city she has never really left.Her first album took her │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ diff --git a/src/ui/readarr_ui/library/author_details_ui.rs b/src/ui/readarr_ui/library/author_details_ui.rs index 4bf12992..5521451f 100644 --- a/src/ui/readarr_ui/library/author_details_ui.rs +++ b/src/ui/readarr_ui/library/author_details_ui.rs @@ -5,7 +5,6 @@ use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; -use regex::Regex; use crate::app::App; use crate::models::Route; @@ -20,7 +19,8 @@ use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::decorate_peer_style; use crate::ui::utils::{ - borderless_block, get_width_from_percentage, layout_block_top_border, title_block, + borderless_block, collapse_whitespace, get_width_from_percentage, layout_block_top_border, + title_block, }; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; @@ -165,18 +165,12 @@ fn draw_author_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .get_by_left(¤t_selection.metadata_profile_id) .cloned() .unwrap_or_default(); - let overview = Regex::new(r"[\r\n\t]") - .unwrap() - .replace_all( - &deunicode( - current_selection - .overview - .as_ref() - .unwrap_or(&String::new()), - ), - "", - ) - .to_string(); + let overview = collapse_whitespace(&deunicode( + current_selection + .overview + .as_ref() + .unwrap_or(&String::new()), + )); let mut author_description = vec![ Line::from(vec![ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap index 3f888ea3..1f6642b5 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap index 1c6d049d..d8352fb7 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap index 8d2e3538..cd274d86 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap index 41abe9ed..a54d032f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap index 09682797..9b45f8c0 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap index a68e71a7..744f8a99 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap index 8695edaf..a333c344 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap index e3c11487..b000e8eb 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap index b50638d0..a423ef9d 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap index 848c8c22..1c097c0f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ ╭───────────────── Download Rejected Release ──────────────────╮ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap index b8c3a0d3..36480ec8 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap index 91a5d219..98da33f3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap index c80452c2..a224f0ea 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap index b96965ce..838810c3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap index fede133e..bfb66384 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap index 07159d3e..3104d489 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap index a19dea43..64dce43f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap index bdd6acc2..2ffd5347 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap index a2effcc8..57bc41c4 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap index e9623818..a9f49210 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap @@ -14,7 +14,7 @@ expression: output │Quality Pro│He was born in Madison, Wisconsin: a city he has never really left. │ │ │Metadata Pr│ │ │ │Monitored: │His first novel took him seven years to finish. │ │ - │Overview: s│ │took him │ + │Overview: s│ │l took him │ │seven years│ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap index 09682797..9b45f8c0 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap index 36b06222..7f5765ba 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap @@ -14,7 +14,7 @@ expression: output │Qual│ │ │ │Meta│ │ │ │Moni│ │ │ - │Over│ │im │ + │Over│ │ him │ │seve│ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap index 4ea2aa15..b0d064ba 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap index 762a0298..4166feb5 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap @@ -17,7 +17,7 @@ expression: output │Books: 3 │ │Files: 2/3 │ │Size on Disk: 3.30 GB │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap index a19dea43..64dce43f 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap index dd9d38b4..520fd679 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap index dd9d38b4..520fd679 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap index d461fa91..bece33db 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap index c6ecfcb5..763b47b0 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap index c6ecfcb5..763b47b0 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap index c6ecfcb5..763b47b0 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap index c6ecfcb5..763b47b0 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap index 10fe588c..eedd0a72 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: St│ │ │ │Metadata Profile: C│ │ │ │Monitored: Yes │ │ │ - │Overview: some inte│ │st novel took him │ + │Overview: some inte│ │irst novel took him │ │seven years to fini│ │ │ │ │ │ │ │ │ ╭───╮ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap index 262fed8e..adc0d386 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -14,7 +14,7 @@ expression: output │Quality Profile: Standard │ │Metadata Profile: Comprehensive │ │Monitored: Yes │ - │Overview: some interesting description of the authorHe was born in Madison, Wisconsin: a city he has never really left.His first novel took him │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ │seven years to finish. │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 7d4ec9d3..72849732 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -6,7 +6,6 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::{Line, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; -use regex::Regex; use crate::app::App; use crate::models::Route; @@ -18,7 +17,8 @@ use crate::ui::sonarr_ui::library::series_overview_ui::SeriesOverviewUi; use crate::ui::sonarr_ui::sonarr_ui_utils::create_history_event_details; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ - borderless_block, get_width_from_percentage, layout_block_top_border, title_block, + borderless_block, collapse_whitespace, get_width_from_percentage, layout_block_top_border, + title_block, }; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; @@ -138,18 +138,12 @@ 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 overview = collapse_whitespace(&deunicode( + current_selection + .overview + .as_ref() + .unwrap_or(&String::new()), + )); let mut series_description = vec![ Line::from(vec![ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap index eaad66e7..df1ec130 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap @@ -11,7 +11,7 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ - │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ │ The first season took seven years to finish. │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap index f4d7ba67..8fd63fef 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap @@ -7,11 +7,11 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap index eaad66e7..df1ec130 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap @@ -11,7 +11,7 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ - │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ │ The first season took seven years to finish. │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap index f4d7ba67..8fd63fef 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap @@ -7,11 +7,11 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap index ce0dcc5b..8336957f 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap @@ -11,7 +11,7 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ - │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ │ The first season took seven years to finish. │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap index e86f0274..1cdb236f 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap @@ -7,11 +7,11 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap index 0f225f03..18ba7e6e 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap @@ -11,7 +11,7 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ - │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ │ The first season took seven years to finish. │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap index 91820af6..ec1c4fb5 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap @@ -7,11 +7,11 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap index 97e1bcc2..8ead2f7c 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap @@ -11,7 +11,7 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ - │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ │ The first season took seven years to finish. │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap index ee29f2c1..9bfa2ad3 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap @@ -7,11 +7,11 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap index 4689b67e..3983b212 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap @@ -11,7 +11,7 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ - │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ │ The first season took seven years to finish. │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap index d638426d..c66ebf07 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap @@ -7,7 +7,7 @@ expression: output => Test 2022 HBO Continuin TV-MA Standard Bluray-1080p English 59.51 GB 🏷 ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap index 36ccbeb0..e8890696 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap index 36ccbeb0..e8890696 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap index 267fbd35..846fe029 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap index 0ba89efe..639490c7 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap index 0ba89efe..639490c7 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap index aa11859a..3dbe2d5c 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap index 1731bf81..399b3102 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap index 0701b977..ddbcd68f 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap index a92caf68..9939fcf7 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap index 94c4e395..0c9f2d32 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap index 4b9eb076..947b56d1 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap index 0343e7b9..4fd79b79 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap index ebca646d..1ca0b628 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap index 8139a786..040aa0db 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap index 29599c10..1912eae8 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap index 02b958da..9fff76f4 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap index 263df9ca..9e57c838 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap index a77fd126..7ba42e9d 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap index d5269529..a45abc4d 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap index 6ac1fb17..4c68a07a 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blahIt was filmed in Madison, Wisconsin: a city the crew never really left.The first season took seven years to finish. │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HB╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ │ │Status: Con│Blah blah blah │ │ │Genres: coo│ │ │ diff --git a/src/ui/utils.rs b/src/ui/utils.rs index c349314f..daeefc34 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -9,8 +9,10 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, BorderType, Borders, LineGauge, ListItem, Paragraph, Wrap}; +use regex::Regex; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; #[cfg(test)] #[path = "utils_tests.rs"] @@ -184,6 +186,14 @@ pub(super) fn decorate_peer_style(seeders: u64, leechers: u64, text: Text<'_>) - } } +static WHITESPACE_RE: OnceLock = OnceLock::new(); + +pub(super) fn collapse_whitespace(text: &str) -> String { + let re = WHITESPACE_RE.get_or_init(|| Regex::new(r"\s+").unwrap()); + + re.replace_all(text.trim(), " ").to_string() +} + pub(super) fn extract_monitored_root_folders( app: &App<'_>, root_folders: Vec, diff --git a/src/ui/utils_tests.rs b/src/ui/utils_tests.rs index d8f073ee..b6895852 100644 --- a/src/ui/utils_tests.rs +++ b/src/ui/utils_tests.rs @@ -4,9 +4,9 @@ mod test { use crate::models::servarr_models::{DiskSpace, RootFolder}; use crate::ui::styles::{ManagarrStyle, default_style, failure_style, secondary_style}; use crate::ui::utils::{ - borderless_block, centered_rect, convert_to_minutes_hours_days, decorate_peer_style, - extract_monitored_disk_space_vec, extract_monitored_root_folders, get_width_from_percentage, - layout_block, layout_block_bottom_border, layout_block_top_border, + borderless_block, centered_rect, collapse_whitespace, convert_to_minutes_hours_days, + decorate_peer_style, extract_monitored_disk_space_vec, extract_monitored_root_folders, + 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, unstyled_title_block, }; @@ -254,6 +254,62 @@ mod test { assert_str_eq!(convert_to_minutes_hours_days(2880), "2 days"); } + #[test] + fn test_collapse_whitespace_replaces_a_line_break_with_a_single_space() { + assert_str_eq!( + collapse_whitespace("the 20th century.\nShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_collapses_a_carriage_return_line_feed_to_a_single_space() { + assert_str_eq!( + collapse_whitespace("the 20th century.\r\nShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_collapses_a_blank_line_and_indentation_to_a_single_space() { + assert_str_eq!( + collapse_whitespace("the 20th century.\r\n\r\n\tShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_does_not_double_a_space_that_precedes_a_line_break() { + assert_str_eq!( + collapse_whitespace("the 20th century. \r\nShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_replaces_a_tab_with_a_single_space() { + assert_str_eq!( + collapse_whitespace("the 20th century.\tShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_trims_surrounding_whitespace() { + assert_str_eq!( + collapse_whitespace("\r\n the 20th century.\r\n"), + "the 20th century." + ); + } + + #[test] + fn test_collapse_whitespace_leaves_single_spaced_text_unchanged() { + assert_str_eq!( + collapse_whitespace("the 20th century. She was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + #[rstest] #[case(0, 0, PeerStyle::Failure)] #[case(1, 2, PeerStyle::Warning)] From 3227495947c28c690a40d1d337ae56dc7caa9457 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 13:51:25 -0600 Subject: [PATCH 130/170] test(lidarr): extract and cover the album row style branches Row styling is invisible to the snapshot harness, because render_to_string_with_app returns a plain String and discards colour and modifiers. The album row style branch was therefore entirely uncovered, and any arm could be swapped for another without a single test noticing. decorate_album_row_with_style now holds that branch, matching decorate_artist_row_with_style in the library view and decorate_book_row_with_style in the Readarr author details view. The extraction is behaviour preserving: the branch is token identical to the one it replaces, and no snapshot changed. Verified by mutation. Against the previous tree all eight mutations left the suite green: replacing each of the unmonitored, downloaded, unreleased, both missing and indeterminate arms, flipping the track file count comparison, and inverting the monitored check. Against the new tests every one of those eight fails. --- src/ui/lidarr_ui/library/artist_details_ui.rs | 59 +++++---- .../library/artist_details_ui_tests.rs | 125 +++++++++++++++++- 2 files changed, 156 insertions(+), 28 deletions(-) diff --git a/src/ui/lidarr_ui/library/artist_details_ui.rs b/src/ui/lidarr_ui/library/artist_details_ui.rs index a0ea6167..168b61ee 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui.rs @@ -288,33 +288,18 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .map_or(0f64, |s| convert_to_gb(s.size_on_disk)); let duration_mins = album.duration / 60000; - let row = Row::new(vec![ - Cell::from(monitored.to_owned()), - Cell::from(album.title.to_string()), - Cell::from(album_type), - Cell::from(track_count), - Cell::from(format!("{duration_mins} min")), - Cell::from(release_date), - Cell::from(format!("{size:.2} GB")), - ]); - - if !album.monitored { - row.unmonitored() - } else if let Some(stats) = album.statistics.as_ref() { - if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 { - row.downloaded() - } else if let Some(release_date) = album.release_date.as_ref() { - if release_date > &Utc::now() { - row.unreleased() - } else { - row.missing() - } - } else { - row.missing() - } - } else { - row.indeterminate() - } + decorate_album_row_with_style( + album, + Row::new(vec![ + Cell::from(monitored.to_owned()), + Cell::from(album.title.to_string()), + Cell::from(album_type), + Cell::from(track_count), + Cell::from(format!("{duration_mins} min")), + Cell::from(release_date), + Cell::from(format!("{size:.2} GB")), + ]), + ) }; let is_searching = active_lidarr_block == ActiveLidarrBlock::SearchAlbums; @@ -350,6 +335,26 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } } +fn decorate_album_row_with_style<'a>(album: &Album, row: Row<'a>) -> Row<'a> { + if !album.monitored { + row.unmonitored() + } else if let Some(stats) = album.statistics.as_ref() { + if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 { + row.downloaded() + } else if let Some(release_date) = album.release_date.as_ref() { + if release_date > &Utc::now() { + row.unreleased() + } else { + row.missing() + } + } else { + row.missing() + } + } else { + row.indeterminate() + } +} + fn draw_artist_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if !app.is_loading { let current_selection = if app.data.lidarr_data.artist_history.is_empty() { diff --git a/src/ui/lidarr_ui/library/artist_details_ui_tests.rs b/src/ui/lidarr_ui/library/artist_details_ui_tests.rs index bd9a00dd..3f3110e6 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui_tests.rs @@ -1,13 +1,20 @@ #[cfg(test)] mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; use strum::IntoEnumIterator; + use crate::models::lidarr_models::{Album, AlbumStatistics}; use crate::models::servarr_data::lidarr::lidarr_data::{ ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, TRACK_DETAILS_BLOCKS, }; use crate::ui::DrawUi; - use crate::ui::lidarr_ui::library::artist_details_ui::ArtistDetailsUi; + use crate::ui::lidarr_ui::library::artist_details_ui::{ + ArtistDetailsUi, decorate_album_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; #[test] fn test_artist_details_ui_accepts() { @@ -26,6 +33,122 @@ mod tests { }); } + #[test] + fn test_decorate_album_row_with_style_unmonitored() { + let album = Album::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_album_row_with_style_downloaded_when_all_tracks_present() { + let album = Album { + monitored: true, + statistics: Some(AlbumStatistics { + track_file_count: 3, + total_track_count: 3, + ..AlbumStatistics::default() + }), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_album_row_with_style_unreleased_when_tracks_are_missing_and_release_date_is_future() + { + let album = Album { + monitored: true, + release_date: Some(Utc::now() + Duration::days(1)), + statistics: Some(AlbumStatistics { + track_file_count: 0, + total_track_count: 3, + ..AlbumStatistics::default() + }), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_album_row_with_style_missing_when_tracks_are_missing_and_release_date_has_passed() + { + let album = Album { + monitored: true, + release_date: Some(Utc::now() - Duration::days(1)), + statistics: Some(AlbumStatistics { + track_file_count: 1, + total_track_count: 3, + ..AlbumStatistics::default() + }), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_album_row_with_style_missing_when_tracks_are_missing_and_no_release_date() { + let album = Album { + monitored: true, + release_date: None, + statistics: Some(AlbumStatistics { + track_file_count: 1, + total_track_count: 3, + ..AlbumStatistics::default() + }), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_album_row_with_style_missing_when_total_track_count_is_zero() { + let album = Album { + monitored: true, + release_date: Some(Utc::now() - Duration::days(1)), + statistics: Some(AlbumStatistics::default()), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_album_row_with_style_indeterminate_when_no_statistics() { + let album = Album { + monitored: true, + statistics: None, + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + mod snapshot_tests { use pretty_assertions::assert_str_eq; use rstest::rstest; From c069ed4a9b47c1ced60ed311fcb37f9c2c0daaed Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 13:52:17 -0600 Subject: [PATCH 131/170] test(sonarr): extract and cover the season row style branches Row styling is invisible to the snapshot harness, because render_to_string_with_app returns a plain String and discards colour and modifiers. The season row style branch was therefore entirely uncovered, and any arm could be swapped for another without a single test noticing. decorate_season_row_with_style now holds that branch, matching decorate_series_row_with_style in the library view. The extraction is behaviour preserving: the branch is token identical to the one it replaces, the statistics fallback resolves the same way, and no snapshot changed. The season row deliberately has no positive episode count guard, so a season without statistics falls back to zero of zero episodes and reads as downloaded; a test now pins that. Verified by mutation. Against the previous tree all seven mutations left the suite green: replacing each of the unmonitored, downloaded, unreleased and both missing arms, flipping the episode file count comparison, and inverting the monitored check. Against the new tests every one of those seven fails. --- src/ui/sonarr_ui/library/series_details_ui.rs | 61 ++++++---- .../library/series_details_ui_tests.rs | 110 +++++++++++++++++- 2 files changed, 146 insertions(+), 25 deletions(-) diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 72849732..94116624 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -230,16 +230,12 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let content = Some(&mut app.data.sonarr_data.seasons); let season_row_mapping = |season: &Season| { let Season { - title, - monitored, - statistics, - .. + title, statistics, .. } = season; let SeasonStatistics { episode_file_count, episode_count, size_on_disk, - next_airing, .. } = if let Some(stats) = statistics { stats @@ -249,25 +245,15 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { 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(title.clone().unwrap_or_default()), - Cell::from(format!("{episode_file_count}/{episode_count}")), - Cell::from(format!("{size:.2} GB")), - ]); - if !monitored { - row.unmonitored() - } else if episode_file_count == episode_count { - row.downloaded() - } else if let Some(next_airing_utc) = next_airing.as_ref() { - if next_airing_utc > &Utc::now() { - row.unreleased() - } else { - row.missing() - } - } else { - row.missing() - } + decorate_season_row_with_style( + season, + Row::new(vec![ + Cell::from(season_monitored.to_owned()), + Cell::from(title.clone().unwrap_or_default()), + Cell::from(format!("{episode_file_count}/{episode_count}")), + Cell::from(format!("{size:.2} GB")), + ]), + ) }; let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchSeason; let season_table = ManagarrTable::new(content, season_row_mapping) @@ -291,6 +277,33 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } } +fn decorate_season_row_with_style<'a>(season: &Season, row: Row<'a>) -> Row<'a> { + let SeasonStatistics { + episode_file_count, + episode_count, + next_airing, + .. + } = if let Some(stats) = &season.statistics { + stats + } else { + &SeasonStatistics::default() + }; + + if !season.monitored { + row.unmonitored() + } else if episode_file_count == episode_count { + row.downloaded() + } else if let Some(next_airing_utc) = next_airing.as_ref() { + if next_airing_utc > &Utc::now() { + row.unreleased() + } else { + row.missing() + } + } else { + row.missing() + } +} + 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 => { 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 88bcc299..f607129a 100644 --- a/src/ui/sonarr_ui/library/series_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -1,5 +1,8 @@ #[cfg(test)] mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; use strum::IntoEnumIterator; use crate::app::App; @@ -7,8 +10,12 @@ mod tests { ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, }; + use crate::models::sonarr_models::{Season, SeasonStatistics}; use crate::ui::DrawUi; - use crate::ui::sonarr_ui::library::series_details_ui::SeriesDetailsUi; + use crate::ui::sonarr_ui::library::series_details_ui::{ + SeriesDetailsUi, decorate_season_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -27,6 +34,107 @@ mod tests { }); } + #[test] + fn test_decorate_season_row_with_style_unmonitored() { + let season = Season::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_season_row_with_style_downloaded_when_all_episodes_present() { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + episode_file_count: 3, + episode_count: 3, + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_season_row_with_style_downloaded_when_no_statistics() { + let season = Season { + monitored: true, + statistics: None, + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_season_row_with_style_unreleased_when_episodes_are_missing_and_next_airing_is_future() + { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + episode_file_count: 0, + episode_count: 3, + next_airing: Some(Utc::now() + Duration::days(1)), + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_season_row_with_style_missing_when_episodes_are_missing_and_next_airing_has_passed() + { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + episode_file_count: 1, + episode_count: 3, + next_airing: Some(Utc::now() - Duration::days(1)), + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_season_row_with_style_missing_when_episodes_are_missing_and_no_next_airing() { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + episode_file_count: 1, + episode_count: 3, + next_airing: None, + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.missing()); + } + mod snapshot_tests { use crate::models::stateful_table::StatefulTable; use crate::ui::ui_test_utils::test_utils::TerminalSize; From db8e97a70081bce6c3bef50863f2d70651506fbc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 15:09:40 -0600 Subject: [PATCH 132/170] fix(sonarr): stop marking seasons fully downloaded when no episodes are known A monitored season rendered in the fully downloaded colour whenever Sonarr returned no statistics for it, and whenever it genuinely had zero known episodes. Both cases compared zero against zero and short circuited to the downloaded style, claiming the user had every episode of a season that was only announced, or that had never been scanned. Seasons whose statistics are absent now render as indeterminate, matching the series row and the equivalent album and book rows in the other trees, and the downloaded style now requires a positive episode count. --- src/ui/sonarr_ui/library/series_details_ui.rs | 27 +++++------ .../library/series_details_ui_tests.rs | 45 +++++++++++++------ 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 94116624..489edae1 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -278,29 +278,22 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } fn decorate_season_row_with_style<'a>(season: &Season, row: Row<'a>) -> Row<'a> { - let SeasonStatistics { - episode_file_count, - episode_count, - next_airing, - .. - } = if let Some(stats) = &season.statistics { - stats - } else { - &SeasonStatistics::default() - }; - if !season.monitored { row.unmonitored() - } else if episode_file_count == episode_count { - row.downloaded() - } else if let Some(next_airing_utc) = next_airing.as_ref() { - if next_airing_utc > &Utc::now() { - row.unreleased() + } else if let Some(stats) = season.statistics.as_ref() { + if stats.episode_file_count == stats.episode_count && stats.episode_count > 0 { + row.downloaded() + } else if let Some(next_airing) = stats.next_airing.as_ref() { + if next_airing > &Utc::now() { + row.unreleased() + } else { + row.missing() + } } else { row.missing() } } else { - row.missing() + row.indeterminate() } } 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 f607129a..302716a3 100644 --- a/src/ui/sonarr_ui/library/series_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -62,20 +62,6 @@ mod tests { assert_eq!(style, row.downloaded()); } - #[test] - fn test_decorate_season_row_with_style_downloaded_when_no_statistics() { - let season = Season { - monitored: true, - statistics: None, - ..Season::default() - }; - let row = Row::new(vec![Cell::from("test".to_owned())]); - - let style = decorate_season_row_with_style(&season, row.clone()); - - assert_eq!(style, row.downloaded()); - } - #[test] fn test_decorate_season_row_with_style_unreleased_when_episodes_are_missing_and_next_airing_is_future() { @@ -135,6 +121,37 @@ mod tests { assert_eq!(style, row.missing()); } + #[test] + fn test_decorate_season_row_with_style_missing_when_episode_count_is_zero() { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + next_airing: Some(Utc::now() - Duration::days(1)), + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_season_row_with_style_indeterminate_when_no_statistics() { + let season = Season { + monitored: true, + statistics: None, + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + mod snapshot_tests { use crate::models::stateful_table::StatefulTable; use crate::ui::ui_test_utils::test_utils::TerminalSize; From fbf711f4283f0d3ac75ae8b4d603d5e8352b6cf6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 16:19:25 -0600 Subject: [PATCH 133/170] test(lidarr): extract and cover the track row style branch The snapshot harness renders to a plain string, so colour and text modifiers never reach the assertion. The track row's downloaded and missing arms were therefore invisible to the suite: swapping either arm for the other, or inverting the has_file condition, all left the full run green. Move the branch into decorate_track_row_with_style, mirroring the row decorators already extracted in the other library detail views, and unit test both arms directly against Row equality. All three mutations now fail the suite. --- src/ui/lidarr_ui/library/album_details_ui.rs | 32 +++++++++-------- .../library/album_details_ui_tests.rs | 34 ++++++++++++++++++- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/ui/lidarr_ui/library/album_details_ui.rs b/src/ui/lidarr_ui/library/album_details_ui.rs index 66871a58..31628d86 100644 --- a/src/ui/lidarr_ui/library/album_details_ui.rs +++ b/src/ui/lidarr_ui/library/album_details_ui.rs @@ -157,7 +157,6 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { title, duration, track_file_id, - has_file, .. } = track; @@ -187,19 +186,16 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let secs = duration_secs % 60; let duration_str = format!("{mins}:{secs:02}"); - let row = Row::new(vec![ - Cell::from(track_number.clone()), - Cell::from(title.clone()), - Cell::from(duration_str), - Cell::from(audio_info), - Cell::from(quality), - ]); - - if *has_file { - row.downloaded() - } else { - row.missing() - } + decorate_track_row_with_style( + track, + Row::new(vec![ + Cell::from(track_number.clone()), + Cell::from(title.clone()), + Cell::from(duration_str), + Cell::from(audio_info), + Cell::from(quality), + ]), + ) }; let is_searching = active_lidarr_block == ActiveLidarrBlock::SearchTracks; @@ -225,6 +221,14 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } } +fn decorate_track_row_with_style<'a>(track: &Track, row: Row<'a>) -> Row<'a> { + if track.has_file { + row.downloaded() + } else { + row.missing() + } +} + fn draw_album_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { match app.data.lidarr_data.album_details_modal.as_ref() { Some(album_details_modal) if !app.is_loading => { diff --git a/src/ui/lidarr_ui/library/album_details_ui_tests.rs b/src/ui/lidarr_ui/library/album_details_ui_tests.rs index 874ba338..260fb8af 100644 --- a/src/ui/lidarr_ui/library/album_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/album_details_ui_tests.rs @@ -1,14 +1,20 @@ #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; use strum::IntoEnumIterator; use crate::app::App; + use crate::models::lidarr_models::Track; use crate::models::servarr_data::lidarr::lidarr_data::{ ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock, TRACK_DETAILS_BLOCKS, }; use crate::models::stateful_table::StatefulTable; use crate::ui::DrawUi; - use crate::ui::lidarr_ui::library::album_details_ui::AlbumDetailsUi; + use crate::ui::lidarr_ui::library::album_details_ui::{ + AlbumDetailsUi, decorate_track_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -25,6 +31,32 @@ mod tests { }); } + #[test] + fn test_decorate_track_row_with_style_downloaded_when_track_has_file() { + let track = Track { + has_file: true, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&track, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_track_row_with_style_missing_when_track_has_no_file() { + let track = Track { + has_file: false, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&track, row.clone()); + + assert_eq!(style, row.missing()); + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; From 9dbd98550682958d2c106c84d5bf084a4506d1ca Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 11 Sep 2026 11:27:27 -0600 Subject: [PATCH 134/170] fix(readarr): attach editions when toggling book monitoring Toggling book monitoring failed with an HTTP 500, "Value cannot be null. (Parameter 'source')", every time, in both the CLI and the TUI. GET /book/{id} omits the editions field entirely, but PUT /book/{id} requires it, so round-tripping Readarr's own response back as an update was always rejected. Editions are served separately from GET /edition?bookId=, so fetch them and attach them to the body before the PUT. --- .../readarr_network/library/books/mod.rs | 36 +++++- .../books/readarr_books_network_tests.rs | 103 ++++++++++++++++++ 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index 8db329fd..eaa28f47 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -275,10 +275,34 @@ impl Network<'_, '_> { }) .await?; + info!("Fetching book editions for book with ID: {book_id}"); + + let editions_event = ReadarrEvent::GetBookEditions(book_id); + let request_props = self + .request_props_from( + editions_event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("bookId={book_id}")), + ) + .await; + + let mut editions_response = String::new(); + + self + .handle_request::<(), Value>(request_props, |editions_body, _| { + editions_response = editions_body.to_string() + }) + .await?; + info!("Constructing toggle book monitoring body"); - match serde_json::from_str::(&response) { - Ok(mut detailed_book_body) => { + match ( + serde_json::from_str::(&response), + serde_json::from_str::(&editions_response), + ) { + (Ok(mut detailed_book_body), Ok(editions)) => { let monitored = detailed_book_body .get("monitored") .unwrap() @@ -286,6 +310,10 @@ impl Network<'_, '_> { .unwrap(); *detailed_book_body.get_mut("monitored").unwrap() = json!(!monitored); + detailed_book_body + .as_object_mut() + .unwrap() + .insert("editions".to_owned(), editions); debug!("Toggle book monitoring body: {detailed_book_body:?}"); @@ -303,8 +331,8 @@ impl Network<'_, '_> { .handle_request::(request_props, |_, _| ()) .await } - Err(_) => { - warn!("Request for detailed book body was interrupted"); + _ => { + warn!("Request for detailed book body or book editions was interrupted"); Ok(()) } } diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index 85c84849..7be0fdaf 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -1020,15 +1020,53 @@ mod tests { #[case] initial_monitored: bool, #[case] expected_monitored: bool, ) { + let editions_json = json!([ + { + "id": 2, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id-2", + "monitored": false, + "isEbook": false, + "title": "Second Edition", + "language": "eng", + "overview": "the second edition", + "format": "Hardcover", + "publisher": "Second Publisher", + "pageCount": 128, + "releaseDate": "2023-02-01T00:00:00Z", + "isbn13": "9780000000002", + "asin": "B000000002", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }, + serde_json::from_str::(EDITION_JSON).unwrap() + ]); let mut book_json: Value = serde_json::from_str(BOOK_JSON).unwrap(); *book_json.get_mut("monitored").unwrap() = json!(initial_monitored); let mut expected_body = book_json.clone(); *expected_body.get_mut("monitored").unwrap() = json!(expected_monitored); + expected_body + .as_object_mut() + .unwrap() + .insert("editions".to_owned(), editions_json.clone()); let (async_details_server, app, mut server) = MockServarrApi::get() .returns(book_json) .path("/1") .build_for(ReadarrEvent::GetBookDetails(1)) .await; + let async_editions_server = server + .mock( + "GET", + format!( + "/api/v1{}?bookId=1", + ReadarrEvent::GetBookEditions(1).resource() + ) + .as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .with_body(editions_json.to_string()) + .create_async() + .await; let async_toggle_server = server .mock( "PUT", @@ -1051,8 +1089,10 @@ mod tests { .await; async_details_server.assert_async().await; + async_editions_server.assert_async().await; async_toggle_server.assert_async().await; assert_ok!(result); + assert_none!(app.lock().await.data.readarr_data.book_details_modal); } #[tokio::test] @@ -1063,6 +1103,20 @@ mod tests { .path("/1") .build_for(ReadarrEvent::GetBookDetails(1)) .await; + let async_editions_server = server + .mock( + "GET", + format!( + "/api/v1{}?bookId=1", + ReadarrEvent::GetBookEditions(1).resource() + ) + .as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; let async_toggle_server = server .mock( "PUT", @@ -1085,6 +1139,55 @@ mod tests { .await; async_details_server.assert_async().await; + async_editions_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_toggle_book_monitoring_event_editions_failure() { + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(BOOK_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + let async_editions_server = server + .mock( + "GET", + format!( + "/api/v1{}?bookId=1", + ReadarrEvent::GetBookEditions(1).resource() + ) + .as_str(), + ) + .with_status(404) + .match_header("X-Api-Key", "test1234") + .with_body(json!([serde_json::from_str::(EDITION_JSON).unwrap()]).to_string()) + .create_async() + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleBookMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleBookMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_editions_server.assert_async().await; async_toggle_server.assert_async().await; assert_err!(result); } From 86b91ce6dd2e51b5106c5799e7a24013828dfdaa Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 11 Sep 2026 12:27:53 -0600 Subject: [PATCH 135/170] fix: keep history readable when an event type is unrecognised A single history record whose event type the Servarr build has no name for made the entire history response fail to decode, so the view died with "Failed to parse response!" and showed nothing at all. The event type is stored as an integer and is only serialized as a camelCase string when the build can name it. For values it cannot name, it is emitted as a bare JSON number instead, which is a type mismatch rather than merely an unknown name. Serde's other attribute does not help: it is rejected outright unless it sits on the last variant, and even there it only catches unrecognised strings, leaving a number to fail exactly as before. from_json_or_default buffers the field and falls back to the type's default for any input the enum does not recognise, whether that is an unknown string, a number, or any other JSON value. It is applied to the event type of all four history item types. Known values still map exactly as they did, and serialization is untouched. Verified against a live Readarr carrying such a record: the history listing failed to decode before the change, and afterwards lists every record, rendering the unnamed one as Unknown. Verified by mutation. Seven mutations all fail the new suite: dropping the fallback, discarding the decoded value, removing the attribute from each of the four history items, and renaming a variant on serialize only. The five per-tree and serialize-only mutations were each invisible to every pre-existing test. --- src/models/lidarr_models.rs | 1 + src/models/lidarr_models_tests.rs | 150 ++++++++++++++++++++++++++++- src/models/mod.rs | 11 ++- src/models/model_tests.rs | 17 +++- src/models/radarr_models.rs | 1 + src/models/radarr_models_tests.rs | 126 +++++++++++++++++++++++- src/models/readarr_models.rs | 1 + src/models/readarr_models_tests.rs | 149 +++++++++++++++++++++++++++- src/models/sonarr_models.rs | 1 + src/models/sonarr_models_tests.rs | 127 +++++++++++++++++++++++- 10 files changed, 578 insertions(+), 6 deletions(-) diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 333d51d9..74fe95b1 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -404,6 +404,7 @@ pub struct LidarrHistoryItem { #[serde(default)] pub quality: QualityWrapper, pub date: DateTime, + #[serde(deserialize_with = "super::from_json_or_default")] pub event_type: LidarrHistoryEventType, #[serde(default)] pub data: LidarrHistoryData, diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index f3fd6ad4..618d6f5f 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -2,7 +2,7 @@ mod tests { use chrono::Utc; use pretty_assertions::{assert_eq, assert_str_eq}; - use serde_json::json; + use serde_json::{Value, json}; use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, @@ -773,6 +773,126 @@ mod tests { ); } + #[test] + fn test_lidarr_history_event_type_deserialization() { + assert_eq!( + deserialize_history_event_type(json!("unknown")), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!("grabbed")), + LidarrHistoryEventType::Grabbed + ); + assert_eq!( + deserialize_history_event_type(json!("artistFolderImported")), + LidarrHistoryEventType::ArtistFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("albumImportIncomplete")), + LidarrHistoryEventType::AlbumImportIncomplete + ); + assert_eq!( + deserialize_history_event_type(json!("downloadIgnored")), + LidarrHistoryEventType::DownloadIgnored + ); + assert_eq!( + deserialize_history_event_type(json!("downloadImported")), + LidarrHistoryEventType::DownloadImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFailed")), + LidarrHistoryEventType::DownloadFailed + ); + assert_eq!( + deserialize_history_event_type(json!("trackFileDeleted")), + LidarrHistoryEventType::TrackFileDeleted + ); + assert_eq!( + deserialize_history_event_type(json!("trackFileImported")), + LidarrHistoryEventType::TrackFileImported + ); + assert_eq!( + deserialize_history_event_type(json!("trackFileRenamed")), + LidarrHistoryEventType::TrackFileRenamed + ); + assert_eq!( + deserialize_history_event_type(json!("trackFileRetagged")), + LidarrHistoryEventType::TrackFileRetagged + ); + } + + #[test] + fn test_lidarr_history_event_type_deserialization_falls_back_to_unknown() { + assert_eq!( + deserialize_history_event_type(json!("albumImportPartiallyComplete")), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(2)), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(11)), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(null)), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!({ "id": 2 })), + LidarrHistoryEventType::Unknown + ); + } + + #[test] + fn test_lidarr_history_event_type_serialization() { + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::Unknown), + "unknown" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::Grabbed), + "grabbed" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::ArtistFolderImported), + "artistFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::AlbumImportIncomplete), + "albumImportIncomplete" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::DownloadIgnored), + "downloadIgnored" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::DownloadImported), + "downloadImported" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::DownloadFailed), + "downloadFailed" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::TrackFileDeleted), + "trackFileDeleted" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::TrackFileImported), + "trackFileImported" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::TrackFileRenamed), + "trackFileRenamed" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::TrackFileRetagged), + "trackFileRetagged" + ); + } + #[test] fn test_add_artist_search_result_deserialization() { let search_result_json = json!({ @@ -825,4 +945,32 @@ mod tests { assert!(search_result.genres.is_empty()); assert_none!(&search_result.ratings); } + + fn deserialize_history_event_type(event_type: Value) -> LidarrHistoryEventType { + let history_item_json = json!({ + "id": 1, + "sourceTitle": "Test Source Title", + "albumId": 1, + "artistId": 1, + "trackId": 1, + "date": "2024-01-01T00:00:00Z", + "eventType": event_type + }); + + serde_json::from_value::(history_item_json) + .unwrap() + .event_type + } + + fn serialize_history_event_type(event_type: LidarrHistoryEventType) -> String { + let history_item = LidarrHistoryItem { + event_type, + ..LidarrHistoryItem::default() + }; + + serde_json::to_value(history_item).unwrap()["eventType"] + .as_str() + .unwrap() + .to_owned() + } } diff --git a/src/models/mod.rs b/src/models/mod.rs index 867da8b1..870be97d 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -11,7 +11,7 @@ use radarr_models::RadarrSerdeable; use readarr_models::ReadarrSerdeable; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; -use serde_json::Number; +use serde_json::{Number, Value}; use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use sonarr_models::SonarrSerdeable; @@ -442,6 +442,15 @@ where ))) } +pub fn from_json_or_default<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: Deserialize<'de> + Default, +{ + let value: Value = Deserialize::deserialize(deserializer)?; + Ok(T::deserialize(value).unwrap_or_default()) +} + pub fn strip_non_search_characters(input: &str) -> String { Regex::new(r"[^a-zA-Z0-9.,/'\-:\s]") .unwrap() diff --git a/src/models/model_tests.rs b/src/models/model_tests.rs index 1b8dc3e7..3f572369 100644 --- a/src/models/model_tests.rs +++ b/src/models/model_tests.rs @@ -11,12 +11,13 @@ mod tests { use crate::models::{ BlockSelectionState, HorizontallyScrollableText, Scrollable, ScrollableText, TabRoute, TabState, }; - use crate::models::{from_i64, strip_non_search_characters}; + use crate::models::{from_i64, from_json_or_default, strip_non_search_characters}; use pretty_assertions::{assert_eq, assert_str_eq}; use serde::de::IntoDeserializer; use serde::de::value::Error as ValueError; use serde::de::value::F64Deserializer; use serde::de::value::I64Deserializer; + use serde::de::value::StrDeserializer; use serde_json::to_string; const BLOCKS: &[&[i32]] = &[&[11, 12], &[21, 22], &[31, 32]]; @@ -803,6 +804,20 @@ mod tests { assert_ok_eq_x!(from_f64(deserializer), 1.0); } + #[test] + fn test_from_json_or_default() { + let deserializer: I64Deserializer = 1i64.into_deserializer(); + + assert_ok_eq_x!(from_json_or_default::<_, i64>(deserializer), 1); + } + + #[test] + fn test_from_json_or_default_falls_back_to_default() { + let deserializer: StrDeserializer<'_, ValueError> = "unexpected".into_deserializer(); + + assert_ok_eq_x!(from_json_or_default::<_, i64>(deserializer), 0); + } + #[test] fn test_horizontally_scrollable_serialize() { let text = HorizontallyScrollableText::from("Test"); diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 8b59fd3d..c71fe71f 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -459,6 +459,7 @@ pub struct RadarrHistoryItem { pub quality: QualityWrapper, pub languages: Vec, pub date: DateTime, + #[serde(deserialize_with = "super::from_json_or_default")] pub event_type: RadarrHistoryEventType, #[serde(default)] pub data: RadarrHistoryData, diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index b9ed2485..a114a17d 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; - use serde_json::json; + use serde_json::{Value, json}; use crate::models::radarr_models::{ RadarrHistoryEventType, RadarrHistoryItem, RadarrHistoryWrapper, @@ -126,6 +126,102 @@ mod tests { ); } + #[test] + fn test_radarr_history_event_type_deserialization() { + assert_eq!( + deserialize_history_event_type(json!("unknown")), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!("grabbed")), + RadarrHistoryEventType::Grabbed + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFolderImported")), + RadarrHistoryEventType::DownloadFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFailed")), + RadarrHistoryEventType::DownloadFailed + ); + assert_eq!( + deserialize_history_event_type(json!("movieFileDeleted")), + RadarrHistoryEventType::MovieFileDeleted + ); + assert_eq!( + deserialize_history_event_type(json!("movieFolderImported")), + RadarrHistoryEventType::MovieFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("movieFileRenamed")), + RadarrHistoryEventType::MovieFileRenamed + ); + assert_eq!( + deserialize_history_event_type(json!("downloadIgnored")), + RadarrHistoryEventType::DownloadIgnored + ); + } + + #[test] + fn test_radarr_history_event_type_deserialization_falls_back_to_unknown() { + assert_eq!( + deserialize_history_event_type(json!("movieFileImportFailed")), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(2)), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(11)), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(null)), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!({ "id": 2 })), + RadarrHistoryEventType::Unknown + ); + } + + #[test] + fn test_radarr_history_event_type_serialization() { + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::Unknown), + "unknown" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::Grabbed), + "grabbed" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::DownloadFolderImported), + "downloadFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::DownloadFailed), + "downloadFailed" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::MovieFileDeleted), + "movieFileDeleted" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::MovieFolderImported), + "movieFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::MovieFileRenamed), + "movieFileRenamed" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::DownloadIgnored), + "downloadIgnored" + ); + } + #[test] fn test_download_record_default_indexer_value() { let json = r#"{ @@ -517,4 +613,32 @@ mod tests { RadarrSerdeable::IndexerTestResults(indexer_test_results) ); } + + fn deserialize_history_event_type(event_type: Value) -> RadarrHistoryEventType { + let history_item_json = json!({ + "id": 1, + "sourceTitle": "Test Source Title", + "movieId": 1, + "quality": { "quality": { "name": "HD - 1080p" } }, + "languages": [], + "date": "2024-01-01T00:00:00Z", + "eventType": event_type + }); + + serde_json::from_value::(history_item_json) + .unwrap() + .event_type + } + + fn serialize_history_event_type(event_type: RadarrHistoryEventType) -> String { + let history_item = RadarrHistoryItem { + event_type, + ..RadarrHistoryItem::default() + }; + + serde_json::to_value(history_item).unwrap()["eventType"] + .as_str() + .unwrap() + .to_owned() + } } diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index bb2f10e6..17dc3257 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -413,6 +413,7 @@ pub struct ReadarrHistoryItem { #[serde(default)] pub quality: QualityWrapper, pub date: DateTime, + #[serde(deserialize_with = "super::from_json_or_default")] pub event_type: ReadarrHistoryEventType, #[serde(default)] pub data: ReadarrHistoryData, diff --git a/src/models/readarr_models_tests.rs b/src/models/readarr_models_tests.rs index fe163400..20ffb5b5 100644 --- a/src/models/readarr_models_tests.rs +++ b/src/models/readarr_models_tests.rs @@ -2,7 +2,7 @@ mod tests { use chrono::Utc; use pretty_assertions::{assert_eq, assert_str_eq}; - use serde_json::json; + use serde_json::{Value, json}; use crate::models::readarr_models::{ AddAuthorSearchResult, BlocklistItem, BlocklistResponse, Book, BookFile, DownloadRecord, @@ -758,6 +758,126 @@ mod tests { ); } + #[test] + fn test_readarr_history_event_type_deserialization() { + assert_eq!( + deserialize_history_event_type(json!("unknown")), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!("grabbed")), + ReadarrHistoryEventType::Grabbed + ); + assert_eq!( + deserialize_history_event_type(json!("authorFolderImported")), + ReadarrHistoryEventType::AuthorFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("bookImportIncomplete")), + ReadarrHistoryEventType::BookImportIncomplete + ); + assert_eq!( + deserialize_history_event_type(json!("downloadIgnored")), + ReadarrHistoryEventType::DownloadIgnored + ); + assert_eq!( + deserialize_history_event_type(json!("downloadImported")), + ReadarrHistoryEventType::DownloadImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFailed")), + ReadarrHistoryEventType::DownloadFailed + ); + assert_eq!( + deserialize_history_event_type(json!("bookFileDeleted")), + ReadarrHistoryEventType::BookFileDeleted + ); + assert_eq!( + deserialize_history_event_type(json!("bookFileImported")), + ReadarrHistoryEventType::BookFileImported + ); + assert_eq!( + deserialize_history_event_type(json!("bookFileRenamed")), + ReadarrHistoryEventType::BookFileRenamed + ); + assert_eq!( + deserialize_history_event_type(json!("bookFileRetagged")), + ReadarrHistoryEventType::BookFileRetagged + ); + } + + #[test] + fn test_readarr_history_event_type_deserialization_falls_back_to_unknown() { + assert_eq!( + deserialize_history_event_type(json!("bookImportPartiallyComplete")), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(2)), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(11)), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(null)), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!({ "id": 2 })), + ReadarrHistoryEventType::Unknown + ); + } + + #[test] + fn test_readarr_history_event_type_serialization() { + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::Unknown), + "unknown" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::Grabbed), + "grabbed" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::AuthorFolderImported), + "authorFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookImportIncomplete), + "bookImportIncomplete" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::DownloadIgnored), + "downloadIgnored" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::DownloadImported), + "downloadImported" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::DownloadFailed), + "downloadFailed" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookFileDeleted), + "bookFileDeleted" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookFileImported), + "bookFileImported" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookFileRenamed), + "bookFileRenamed" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookFileRetagged), + "bookFileRetagged" + ); + } + #[test] fn test_readarr_task_name_display() { assert_str_eq!( @@ -1019,4 +1139,31 @@ mod tests { assert!(search_result.genres.is_empty()); assert_none!(&search_result.ratings); } + + fn deserialize_history_event_type(event_type: Value) -> ReadarrHistoryEventType { + let history_item_json = json!({ + "id": 1, + "authorId": 1, + "bookId": 1, + "sourceTitle": "Test Source Title", + "date": "2024-01-01T00:00:00Z", + "eventType": event_type + }); + + serde_json::from_value::(history_item_json) + .unwrap() + .event_type + } + + fn serialize_history_event_type(event_type: ReadarrHistoryEventType) -> String { + let history_item = ReadarrHistoryItem { + event_type, + ..ReadarrHistoryItem::default() + }; + + serde_json::to_value(history_item).unwrap()["eventType"] + .as_str() + .unwrap() + .to_owned() + } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 1b139543..11ee02d2 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -499,6 +499,7 @@ pub struct SonarrHistoryItem { pub quality: QualityWrapper, pub languages: Vec>, pub date: DateTime, + #[serde(deserialize_with = "super::from_json_or_default")] pub event_type: SonarrHistoryEventType, pub data: SonarrHistoryData, } diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index d7ea2026..8e82559a 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; - use serde_json::json; + use serde_json::{Value, json}; use crate::models::{ Serdeable, @@ -213,6 +213,102 @@ mod tests { ); } + #[test] + fn test_sonarr_history_event_type_deserialization() { + assert_eq!( + deserialize_history_event_type(json!("unknown")), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!("grabbed")), + SonarrHistoryEventType::Grabbed + ); + assert_eq!( + deserialize_history_event_type(json!("seriesFolderImported")), + SonarrHistoryEventType::SeriesFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFolderImported")), + SonarrHistoryEventType::DownloadFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFailed")), + SonarrHistoryEventType::DownloadFailed + ); + assert_eq!( + deserialize_history_event_type(json!("episodeFileDeleted")), + SonarrHistoryEventType::EpisodeFileDeleted + ); + assert_eq!( + deserialize_history_event_type(json!("episodeFileRenamed")), + SonarrHistoryEventType::EpisodeFileRenamed + ); + assert_eq!( + deserialize_history_event_type(json!("downloadIgnored")), + SonarrHistoryEventType::DownloadIgnored + ); + } + + #[test] + fn test_sonarr_history_event_type_deserialization_falls_back_to_unknown() { + assert_eq!( + deserialize_history_event_type(json!("episodeFileImportFailed")), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(2)), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(11)), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(null)), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!({ "id": 2 })), + SonarrHistoryEventType::Unknown + ); + } + + #[test] + fn test_sonarr_history_event_type_serialization() { + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::Unknown), + "unknown" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::Grabbed), + "grabbed" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::SeriesFolderImported), + "seriesFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::DownloadFolderImported), + "downloadFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::DownloadFailed), + "downloadFailed" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::EpisodeFileDeleted), + "episodeFileDeleted" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::EpisodeFileRenamed), + "episodeFileRenamed" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::DownloadIgnored), + "downloadIgnored" + ); + } + #[test] fn test_task_name_display() { assert_str_eq!( @@ -601,4 +697,33 @@ mod tests { SonarrSerdeable::IndexerTestResults(indexer_test_results) ); } + + fn deserialize_history_event_type(event_type: Value) -> SonarrHistoryEventType { + let history_item_json = json!({ + "id": 1, + "sourceTitle": "Test Source Title", + "episodeId": 1, + "quality": { "quality": { "name": "HDTV - 1080p" } }, + "languages": [], + "date": "2024-01-01T00:00:00Z", + "eventType": event_type, + "data": {} + }); + + serde_json::from_value::(history_item_json) + .unwrap() + .event_type + } + + fn serialize_history_event_type(event_type: SonarrHistoryEventType) -> String { + let history_item = SonarrHistoryItem { + event_type, + ..SonarrHistoryItem::default() + }; + + serde_json::to_value(history_item).unwrap()["eventType"] + .as_str() + .unwrap() + .to_owned() + } } From 1857d5ba15a3a2d1f72428492b44fdf35eaf2e7b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 12:00:27 -0600 Subject: [PATCH 136/170] feat(readarr): show the author overview in the add author prompt The add author confirmation popup previously left the space above the root folder field blank. It now renders the selected search result's overview there, in a fixed six line area matching the Radarr add movie prompt, so the user can confirm they picked the right author before adding. An author without an overview keeps the same layout. The fully populated search result fixture gains a long overview so the snapshot proves the paragraph is clipped to six lines. --- .../servarr_data/readarr/readarr_data.rs | 14 +++++- src/ui/readarr_ui/library/add_author_ui.rs | 12 +++-- .../readarr_ui/library/add_author_ui_tests.rs | 39 +++++++++++++++ ...active_AddAuthorSelectMetadataProfile.snap | 26 +++++----- ...r_modal_active_AddAuthorSelectMonitor.snap | 40 ++++++++-------- ...active_AddAuthorSelectMonitorNewItems.snap | 30 ++++++------ ..._active_AddAuthorSelectQualityProfile.snap | 26 +++++----- ...odal_active_AddAuthorSelectRootFolder.snap | 26 +++++----- ...uthor_modal_active_AddAuthorTagsInput.snap | 16 +++---- ...odal_step_0_AddAuthorSelectRootFolder.snap | 16 +++---- ...r_modal_step_1_AddAuthorSelectMonitor.snap | 16 +++---- ...step_2_AddAuthorSelectMonitorNewItems.snap | 16 +++---- ..._step_3_AddAuthorSelectQualityProfile.snap | 16 +++---- ...step_4_AddAuthorSelectMetadataProfile.snap | 16 +++---- ...uthor_modal_step_5_AddAuthorTagsInput.snap | 16 +++---- ...r_modal_step_6_AddAuthorConfirmPrompt.snap | 16 +++---- ...ui_renders_an_author_with_no_overview.snap | 48 +++++++++++++++++++ ..._renders_the_selected_author_overview.snap | 48 +++++++++++++++++++ 18 files changed, 295 insertions(+), 142 deletions(-) create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_an_author_with_no_overview.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_the_selected_author_overview.snap diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index e5dbcb38..62154f37 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -388,7 +388,19 @@ impl ReadarrData<'_> { foreign_author_id: "test-foreign-id".to_owned(), author_name: "Test Author".into(), status: AuthorStatus::Continuing, - overview: Some("some interesting description of the author".to_owned()), + overview: Some( + "Test Author is an American novelist whose science fiction has been translated into more \ + than thirty languages. Born in a small river town, the author spent a decade as a marine \ + biologist before turning to fiction, and the sea remains a constant presence in the work. \ + The debut novel won several awards and introduced the sprawling shared universe that later \ + books would return to again and again. Critics have praised the meticulous world building \ + and the quiet, humane attention to ordinary people caught up in extraordinary events. The \ + author also writes essays on the history of science, teaches an annual writing workshop, \ + and lives with a large and opinionated cat. A new trilogy is currently in progress and is \ + expected to conclude the long running saga that began with the first book. The final \ + volume has already been announced for next year." + .to_owned(), + ), author_type: Some("Person".to_owned()), disambiguation: Some("American novelist".to_owned()), ratings: Some(Ratings { diff --git a/src/ui/readarr_ui/library/add_author_ui.rs b/src/ui/readarr_ui/library/add_author_ui.rs index ff6bab80..6e1ce4f9 100644 --- a/src/ui/readarr_ui/library/add_author_ui.rs +++ b/src/ui/readarr_ui/library/add_author_ui.rs @@ -11,7 +11,9 @@ use crate::models::servarr_data::readarr::modals::AddAuthorModal; use crate::models::servarr_data::readarr::readarr_data::{ADD_AUTHOR_BLOCKS, ActiveReadarrBlock}; use crate::render_selectable_input_box; use crate::ui::styles::ManagarrStyle; -use crate::ui::utils::{get_width_from_percentage, layout_block, title_block_centered}; +use crate::ui::utils::{ + get_width_from_percentage, layout_block, layout_paragraph_borderless, title_block_centered, +}; use crate::ui::widgets::button::Button; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::managarr_table::ManagarrTable; @@ -259,6 +261,7 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let author_disambiguation = searched_author.disambiguation.clone().unwrap_or_default(); let title = build_add_author_prompt_title(author_name, &author_disambiguation); + let prompt = searched_author.overview.clone().unwrap_or_default(); let yes_no_value = app.data.readarr_data.prompt_confirm; let selected_block = app.data.readarr_data.selected_block.get_active_block(); let highlights = add_author_prompt_highlights(selected_block); @@ -286,7 +289,7 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { f.render_widget(title_block_centered(&title), area); let [ - _, + paragraph_area, root_folder_area, monitor_area, monitor_new_items_area, @@ -296,7 +299,7 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { _, buttons_area, ] = Layout::vertical([ - Constraint::Fill(1), + Constraint::Length(6), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), @@ -309,6 +312,9 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .margin(1) .areas(area); + let prompt_paragraph = layout_paragraph_borderless(&prompt); + f.render_widget(prompt_paragraph, paragraph_area); + let [add_area, cancel_area] = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) .areas(buttons_area); diff --git a/src/ui/readarr_ui/library/add_author_ui_tests.rs b/src/ui/readarr_ui/library/add_author_ui_tests.rs index 667ae129..248dccbe 100644 --- a/src/ui/readarr_ui/library/add_author_ui_tests.rs +++ b/src/ui/readarr_ui/library/add_author_ui_tests.rs @@ -274,6 +274,45 @@ mod tests { ); } + #[test] + fn test_add_author_modal_ui_renders_the_selected_author_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "Test Author is an American novelist"); + assert_not_contains!(output, "announced for next year"); + insta::assert_snapshot!(output); + } + + #[test] + fn test_add_author_modal_ui_renders_an_author_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .add_searched_authors + .as_mut() + .expect("add_searched_authors must be populated") + .items[0] + .overview = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + assert_not_contains!(output, "Test Author is an American novelist"); + insta::assert_snapshot!(output); + } + #[test] fn test_add_author_already_in_library_ui_renders() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap index 617c0da2..4205bbc2 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap @@ -9,21 +9,19 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ - │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ - │ │ │Author Only │ ▼ │ │ │ - │ │ │ │──────────────────────────────╯ │ │ - │ │ │ │──────────────────────────────╮ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │Author Only │──────────────────────────────╮ │ │ │ │ Monito│ │ ▼ │ │ │ │ │ │ │──────────────────────────────╯ │ │ │ │ │ │──────────────────────────────╮ │ │ @@ -34,7 +32,9 @@ expression: output │ │ │ │──────────────────────────────╯ │ │ │ │ │ │──────────────────────────────╮ │ │ │ │ │ │ │ │ │ - │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ │ │ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap index b08e1755..e68a7977 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap @@ -9,32 +9,32 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ - │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ - │ │ │All Books │ ▼ │ │ │ - │ │ │Future Books │──────────────────────────────╯ │ │ - │ │ │Missing Books │──────────────────────────────╮ │ │ - │ │ Monito│Existing Books │ ▼ │ │ │ - │ │ │First Book │──────────────────────────────╯ │ │ - │ │ │Latest Book │──────────────────────────────╮ │ │ - │ │ Qual│None │ ▼ │ │ │ - │ │ │Unknown │──────────────────────────────╯ │ │ - │ │ │ │──────────────────────────────╮ │ │ - │ │ Metad│ │ ▼ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │All Books │──────────────────────────────╮ │ │ + │ │ Monito│Future Books │ ▼ │ │ │ + │ │ │Missing Books │──────────────────────────────╯ │ │ + │ │ │Existing Books │──────────────────────────────╮ │ │ + │ │ Qual│First Book │ ▼ │ │ │ + │ │ │Latest Book │──────────────────────────────╯ │ │ + │ │ │None │──────────────────────────────╮ │ │ + │ │ Metad│Unknown │ ▼ │ │ │ │ │ │ │──────────────────────────────╯ │ │ │ │ │ │──────────────────────────────╮ │ │ │ │ │ │ │ │ │ - │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ │ │ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap index 003db906..9e0245a7 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap @@ -9,23 +9,21 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ - │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ - │ │ │All Books │ ▼ │ │ │ - │ │ │No New Books │──────────────────────────────╯ │ │ - │ │ │New Books │──────────────────────────────╮ │ │ - │ │ Monito│ │ ▼ │ │ │ - │ │ │ │──────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │All Books │──────────────────────────────╮ │ │ + │ │ Monito│No New Books │ ▼ │ │ │ + │ │ │New Books │──────────────────────────────╯ │ │ │ │ │ │──────────────────────────────╮ │ │ │ │ Qual│ │ ▼ │ │ │ │ │ │ │──────────────────────────────╯ │ │ @@ -34,7 +32,9 @@ expression: output │ │ │ │──────────────────────────────╯ │ │ │ │ │ │──────────────────────────────╮ │ │ │ │ │ │ │ │ │ - │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ │ │ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap index eb0c7de1..fb335fb3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap @@ -9,21 +9,19 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ - │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ - │ │ │eBook │ ▼ │ │ │ - │ │ │ │──────────────────────────────╯ │ │ - │ │ │ │──────────────────────────────╮ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │eBook │──────────────────────────────╮ │ │ │ │ Monito│ │ ▼ │ │ │ │ │ │ │──────────────────────────────╯ │ │ │ │ │ │──────────────────────────────╮ │ │ @@ -34,7 +32,9 @@ expression: output │ │ │ │──────────────────────────────╯ │ │ │ │ │ │──────────────────────────────╮ │ │ │ │ │ │ │ │ │ - │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ │ │ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap index 0cd8327a..b6abb631 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap @@ -9,21 +9,19 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ - │ │ ╭───────────────────────────────╮──────────────────────────────╮ │ │ - │ │ │/nfs │ ▼ │ │ │ - │ │ │ │──────────────────────────────╯ │ │ - │ │ │ │──────────────────────────────╮ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │/nfs │──────────────────────────────╮ │ │ │ │ Monito│ │ ▼ │ │ │ │ │ │ │──────────────────────────────╯ │ │ │ │ │ │──────────────────────────────╮ │ │ @@ -34,7 +32,9 @@ expression: output │ │ │ │──────────────────────────────╯ │ │ │ │ │ │──────────────────────────────╮ │ │ │ │ │ │ │ │ │ - │ │ ╰───────────────────────────────╯──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ │ │ │ │ │ │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap index 0ad696bf..00c03203 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap @@ -9,14 +9,12 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ @@ -42,6 +40,8 @@ expression: output │ │ │ │ │ │ │ │ │ │ │ │ + │ │ │ │ + │ │ │ │ │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ │ ││ Add ││ Cancel ││ │ ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap index 0ad696bf..00c03203 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap @@ -9,14 +9,12 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ @@ -42,6 +40,8 @@ expression: output │ │ │ │ │ │ │ │ │ │ │ │ + │ │ │ │ + │ │ │ │ │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ │ ││ Add ││ Cancel ││ │ ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap index 0ad696bf..00c03203 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap @@ -9,14 +9,12 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ @@ -42,6 +40,8 @@ expression: output │ │ │ │ │ │ │ │ │ │ │ │ + │ │ │ │ + │ │ │ │ │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ │ ││ Add ││ Cancel ││ │ ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap index 0ad696bf..00c03203 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap @@ -9,14 +9,12 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ @@ -42,6 +40,8 @@ expression: output │ │ │ │ │ │ │ │ │ │ │ │ + │ │ │ │ + │ │ │ │ │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ │ ││ Add ││ Cancel ││ │ ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap index 0ad696bf..00c03203 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap @@ -9,14 +9,12 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ @@ -42,6 +40,8 @@ expression: output │ │ │ │ │ │ │ │ │ │ │ │ + │ │ │ │ + │ │ │ │ │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ │ ││ Add ││ Cancel ││ │ ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap index 0ad696bf..00c03203 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap @@ -9,14 +9,12 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ @@ -42,6 +40,8 @@ expression: output │ │ │ │ │ │ │ │ │ │ │ │ + │ │ │ │ + │ │ │ │ │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ │ ││ Add ││ Cancel ││ │ ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap index 0ad696bf..00c03203 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap @@ -9,14 +9,12 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ @@ -42,6 +40,8 @@ expression: output │ │ │ │ │ │ │ │ │ │ │ │ + │ │ │ │ + │ │ │ │ │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ │ ││ Add ││ Cancel ││ │ ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap index 0ad696bf..00c03203 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap @@ -9,14 +9,12 @@ expression: output ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ - ╭──────│ │───────╮ - │Test A│ │ │ - ╰──────│ │───────╯ - ╭──────│ │───────╮ - │ ✔ │ │ │ - │=> ✔ │ │ │ - │ │ │ │ - │ │ │ │ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ Root Folder: │/nfs ▼ │ │ │ │ │ ╰─────────────────────────────────────────────────╯ │ │ @@ -42,6 +40,8 @@ expression: output │ │ │ │ │ │ │ │ │ │ │ │ + │ │ │ │ + │ │ │ │ │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ │ ││ Add ││ Cancel ││ │ ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_an_author_with_no_overview.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_an_author_with_no_overview.snap new file mode 100644 index 00000000..b11223ab --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_an_author_with_no_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_the_selected_author_overview.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_the_selected_author_overview.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_the_selected_author_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ From d3a959d2ddc75714396b86001045f9273aa21859 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 12:25:00 -0600 Subject: [PATCH 137/170] fix: stop paging up or down on a single row table from crashing page_up and page_down wrap the selection with a modulus of items_len - 1, which is a remainder by zero when the table holds exactly one row. Ctrl-U or Ctrl-D on such a table panicked the TUI. Both now return early for tables of one row or fewer, mirroring the existing empty-table guard; tables of two or more rows keep their existing paging arithmetic. --- src/models/stateful_table.rs | 4 +-- src/models/stateful_table_tests.rs | 53 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/models/stateful_table.rs b/src/models/stateful_table.rs index 36488197..6ad579c5 100644 --- a/src/models/stateful_table.rs +++ b/src/models/stateful_table.rs @@ -100,7 +100,7 @@ where { fn page_down(&mut self) { let items_len = self.active_items().len(); - if items_len == 0 { + if items_len <= 1 { return; } @@ -113,7 +113,7 @@ where fn page_up(&mut self) { let items_len = self.active_items().len(); - if items_len == 0 { + if items_len <= 1 { return; } diff --git a/src/models/stateful_table_tests.rs b/src/models/stateful_table_tests.rs index 5e0311d0..c1851e69 100644 --- a/src/models/stateful_table_tests.rs +++ b/src/models/stateful_table_tests.rs @@ -239,6 +239,59 @@ mod tests { ); } + #[test] + fn test_stateful_table_pagination_on_single_item_table_performs_no_op() { + let mut stateful_table: StatefulTable = StatefulTable::default(); + stateful_table.set_items(vec!["Test 1".to_owned()]); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + } + + #[test] + fn test_stateful_table_filtered_pagination_on_single_item_table_performs_no_op() { + let mut filtered_stateful_table: StatefulTable = StatefulTable::default(); + filtered_stateful_table.set_filtered_items(vec!["Test 1".to_owned()]); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 0 + ); + + filtered_stateful_table.page_down(); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 0 + ); + + filtered_stateful_table.page_up(); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 0 + ); + } + #[test] fn test_stateful_table_pagination() { let mut stateful_table = StatefulTable::default(); From 87edf1fb9e6b9c3653143bd6d8b69eeb04888e7a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 14:28:30 -0600 Subject: [PATCH 138/170] test: wire the handler property tests and make the proptest recipe run them handler_proptest.rs was committed without ever being declared as a module, and it imported a list_size strategy that did not exist, so it never compiled. The proptest recipe filtered on a test name substring that matched no test. Declare the module, add the two strategies it needs, and pass both property test filters to libtest so the recipe runs the ui and handler property tests. --- justfile | 2 +- src/handlers/handler_proptest.rs | 67 +++++++++++++++++------------- src/handlers/handler_test_utils.rs | 14 ++++++- src/handlers/mod.rs | 3 ++ 4 files changed, 54 insertions(+), 32 deletions(-) diff --git a/justfile b/justfile index e59dd0c7..72333f1a 100644 --- a/justfile +++ b/justfile @@ -49,7 +49,7 @@ doctest: # Run all proptests [group: 'test'] proptest: - @cargo test proptest + @cargo test -- property_tests proptest alias test-snapshots := snapshot-tests # Run all snapshot tests diff --git a/src/handlers/handler_proptest.rs b/src/handlers/handler_proptest.rs index 1e3df759..f136f87f 100644 --- a/src/handlers/handler_proptest.rs +++ b/src/handlers/handler_proptest.rs @@ -3,7 +3,9 @@ mod property_tests { use proptest::prelude::*; use crate::app::App; - use crate::handlers::handler_test_utils::test_utils::proptest_helpers::*; + use crate::handlers::handler_test_utils::test_utils::proptest_helpers::{ + list_size, text_input_string, + }; use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::stateful_table::StatefulTable; @@ -16,11 +18,12 @@ mod property_tests { index in 0usize..1000 ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie - }).collect(); + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + ..Movie::default() + }) + .collect(); table.set_items(movies); @@ -39,11 +42,12 @@ mod property_tests { scroll_amount in 0usize..20 ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie - }).collect(); + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + ..Movie::default() + }) + .collect(); table.set_items(movies); let initial_id = table.current_selection().id; @@ -76,7 +80,7 @@ mod property_tests { let mut app = App::test_default(); let initial_route = app.get_current_route(); - let routes = vec![ + let routes = [ ActiveRadarrBlock::Movies, ActiveRadarrBlock::Collections, ActiveRadarrBlock::Downloads, @@ -111,15 +115,16 @@ mod property_tests { #[test] fn test_table_data_integrity( - list_size in 1usize..100 + list_size in list_size() ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie.title = format!("Movie {}", i).into(); - movie - }).collect(); + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + title: format!("Movie {i}").into(), + ..Movie::default() + }) + .collect(); table.set_items(movies.clone()); let original_count = table.items.len(); @@ -137,11 +142,12 @@ mod property_tests { page_ops in 0usize..10 ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie - }).collect(); + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + ..Movie::default() + }) + .collect(); table.set_items(movies); @@ -164,12 +170,13 @@ mod property_tests { filter_term in text_input_string() ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie.title = format!("Test Movie {}", i % 10).into(); - movie - }).collect(); + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + title: format!("Test Movie {}", i % 10).into(), + ..Movie::default() + }) + .collect(); table.set_items(movies.clone()); let original_size = table.items.len(); diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 4c81d576..7a567b83 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -1,6 +1,6 @@ #[cfg(test)] #[macro_use] -mod test_utils { +pub mod test_utils { #[macro_export] macro_rules! simple_stateful_iterable_vec { ($name:ident) => { @@ -411,4 +411,16 @@ mod test_utils { ); }; } + + pub mod proptest_helpers { + use proptest::prelude::*; + + pub fn list_size() -> impl Strategy { + 1usize..100 + } + + pub fn text_input_string() -> impl Strategy { + "\\PC{0,50}" + } + } } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 7c6a3402..533e6890 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -26,6 +26,9 @@ mod sonarr_handlers; #[path = "handlers_tests.rs"] mod handlers_tests; +#[cfg(test)] +mod handler_proptest; + #[cfg(test)] #[path = "handler_test_utils.rs"] pub mod handler_test_utils; From d678f5777a705bf7c77837b0448dfb4df2f42f91 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 14:22:47 -0600 Subject: [PATCH 139/170] test: cover the status style branches for track and episode details --- .../library/track_details_ui_tests.rs | 29 +++- .../library/episode_details_ui_tests.rs | 160 +++++++++++++++++- 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/src/ui/lidarr_ui/library/track_details_ui_tests.rs b/src/ui/lidarr_ui/library/track_details_ui_tests.rs index cacbb4bd..0651ac24 100644 --- a/src/ui/lidarr_ui/library/track_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/track_details_ui_tests.rs @@ -1,11 +1,14 @@ #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; use strum::IntoEnumIterator; use crate::app::App; + use crate::models::lidarr_models::Track; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, TRACK_DETAILS_BLOCKS}; use crate::ui::DrawUi; - use crate::ui::lidarr_ui::library::track_details_ui::TrackDetailsUi; + use crate::ui::lidarr_ui::library::track_details_ui::{TrackDetailsUi, style_from_status}; + use crate::ui::styles::{downloaded_style, missing_style}; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -19,6 +22,30 @@ mod tests { }); } + #[test] + fn test_style_from_status_downloaded_when_track_has_file() { + let track = Track { + has_file: true, + ..Track::default() + }; + + let style = style_from_status(&track); + + assert_eq!(style, downloaded_style()); + } + + #[test] + fn test_style_from_status_missing_when_track_has_no_file() { + let track = Track { + has_file: false, + ..Track::default() + }; + + let style = style_from_status(&track); + + assert_eq!(style, missing_style()); + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; diff --git a/src/ui/sonarr_ui/library/episode_details_ui_tests.rs b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs index 5eb2c4d9..323a1d45 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs @@ -1,13 +1,20 @@ #[cfg(test)] mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; use strum::IntoEnumIterator; use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, }; + use crate::models::sonarr_models::{DownloadRecord, DownloadStatus, Episode}; use crate::ui::DrawUi; - use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; + use crate::ui::sonarr_ui::library::episode_details_ui::{EpisodeDetailsUi, style_from_status}; + use crate::ui::styles::{ + awaiting_import_style, downloaded_style, downloading_style, missing_style, + unmonitored_missing_style, unmonitored_style, unreleased_style, + }; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -21,6 +28,157 @@ mod tests { }); } + #[test] + fn test_style_from_status_downloading_when_episode_has_no_file_and_download_is_downloading() { + let download = DownloadRecord { + status: DownloadStatus::Downloading, + ..DownloadRecord::default() + }; + let episode = Episode { + has_file: false, + monitored: true, + ..Episode::default() + }; + + let style = style_from_status(Some(&download), &episode); + + assert_eq!(style, downloading_style()); + } + + #[test] + fn test_style_from_status_awaiting_import_when_episode_has_no_file_and_download_is_completed() { + let download = DownloadRecord { + status: DownloadStatus::Completed, + ..DownloadRecord::default() + }; + let episode = Episode { + has_file: false, + monitored: true, + ..Episode::default() + }; + + let style = style_from_status(Some(&download), &episode); + + assert_eq!(style, awaiting_import_style()); + } + + #[test] + fn test_style_from_status_ignores_download_that_is_neither_downloading_nor_completed() { + let download = DownloadRecord { + status: DownloadStatus::Queued, + ..DownloadRecord::default() + }; + let episode = Episode { + has_file: false, + monitored: true, + air_date_utc: None, + ..Episode::default() + }; + + let style = style_from_status(Some(&download), &episode); + + assert_eq!(style, missing_style()); + } + + #[test] + fn test_style_from_status_unmonitored_missing_when_episode_has_no_file_and_is_unmonitored() { + let episode = Episode { + has_file: false, + monitored: false, + air_date_utc: Some(Utc::now() + Duration::days(1)), + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, unmonitored_missing_style()); + } + + #[test] + fn test_style_from_status_unreleased_when_episode_has_no_file_and_air_date_is_future() { + let episode = Episode { + has_file: false, + monitored: true, + air_date_utc: Some(Utc::now() + Duration::days(1)), + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, unreleased_style()); + } + + #[test] + fn test_style_from_status_missing_when_episode_has_no_file_and_air_date_has_passed() { + let episode = Episode { + has_file: false, + monitored: true, + air_date_utc: Some(Utc::now() - Duration::days(1)), + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, missing_style()); + } + + #[test] + fn test_style_from_status_missing_when_episode_has_no_file_and_no_air_date() { + let episode = Episode { + has_file: false, + monitored: true, + air_date_utc: None, + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, missing_style()); + } + + #[test] + fn test_style_from_status_unmonitored_when_episode_has_file_and_is_unmonitored() { + let episode = Episode { + has_file: true, + monitored: false, + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, unmonitored_style()); + } + + #[test] + fn test_style_from_status_downloaded_when_episode_has_file_and_is_monitored() { + let episode = Episode { + has_file: true, + monitored: true, + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, downloaded_style()); + } + + #[test] + fn test_style_from_status_ignores_download_when_episode_has_file() { + let download = DownloadRecord { + status: DownloadStatus::Downloading, + ..DownloadRecord::default() + }; + let episode = Episode { + has_file: true, + monitored: true, + ..Episode::default() + }; + + let style = style_from_status(Some(&download), &episode); + + assert_eq!(style, downloaded_style()); + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; From 9b4b3003b5706e5114c4d5bb841a62d4d45a5259 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 14:33:16 -0600 Subject: [PATCH 140/170] test: give the string input and table filter property tests real assertions --- .../handlers/handler_proptest.txt | 1 + src/handlers/handler_proptest.rs | 71 ++++++++++++++----- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/proptest-regressions/handlers/handler_proptest.txt b/proptest-regressions/handlers/handler_proptest.txt index d088279f..ea581daa 100644 --- a/proptest-regressions/handlers/handler_proptest.txt +++ b/proptest-regressions/handlers/handler_proptest.txt @@ -5,3 +5,4 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 56330c025ad79db641d0eb9f429ab74e95822e1fb015b58f0e158ea674cd42a1 # shrinks to list_size = 1, page_ops = 1 +cc 29df5ff471eb388b0ffde573a959a539e4cf32acd9e1cea5187bba2268dd57f7 # shrinks to list_size = 1, filter_term = "𑛀" diff --git a/src/handlers/handler_proptest.rs b/src/handlers/handler_proptest.rs index f136f87f..5a216c8b 100644 --- a/src/handlers/handler_proptest.rs +++ b/src/handlers/handler_proptest.rs @@ -3,13 +3,16 @@ mod property_tests { use proptest::prelude::*; use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::handle_events; use crate::handlers::handler_test_utils::test_utils::proptest_helpers::{ list_size, text_input_string, }; use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::stateful_table::StatefulTable; - use crate::models::{Paginated, Scrollable}; + use crate::models::{Paginated, Scrollable, strip_non_search_characters}; proptest! { #[test] @@ -104,13 +107,29 @@ mod property_tests { #[test] fn test_string_input_safety(input in text_input_string()) { - let _lowercase = input.to_lowercase(); - let _uppercase = input.to_uppercase(); - let _trimmed = input.trim(); - let _len = input.len(); - let _chars: Vec = input.chars().collect(); + let mut app = App::test_default(); + app.data.radarr_data.movies.set_items(vec![Movie::default()]); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + handle_events(DEFAULT_KEYBINDINGS.filter.key, &mut app); - prop_assert!(true); + for character in input.chars() { + handle_events(Key::Char(character), &mut app); + } + + prop_assert_eq!(app.get_current_route(), ActiveRadarrBlock::FilterMovies.into()); + prop_assert_eq!( + app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(), + input.as_str() + ); + + for _ in 0..input.chars().count() { + handle_events(DEFAULT_KEYBINDINGS.backspace.key, &mut app); + } + + prop_assert_eq!( + app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(), + "" + ); } #[test] @@ -177,22 +196,36 @@ mod property_tests { ..Movie::default() }) .collect(); - table.set_items(movies.clone()); - let original_size = table.items.len(); + table.filter = Some(filter_term.clone().into()); + let scrubbed_filter = strip_non_search_characters(&filter_term); + let matches_filter = |movie: &Movie| { + strip_non_search_characters(&movie.title.text).contains(&scrubbed_filter) + }; - if !filter_term.is_empty() { - let filtered: Vec = movies.into_iter() - .filter(|m| m.title.text.to_lowercase().contains(&filter_term.to_lowercase())) - .collect(); - table.set_items(filtered); - } + let has_match = table.apply_filter(|movie| &movie.title.text); - prop_assert!(table.items.len() <= original_size); + prop_assert_eq!(&table.items, &movies); + prop_assert!(table.filter.is_none()); - if !table.items.is_empty() { - let current = table.current_selection(); - prop_assert!(current.id >= 0); + if has_match { + let filtered_items = table.filtered_items.as_ref().unwrap(); + + prop_assert!(!filtered_items.is_empty()); + prop_assert!(filtered_items.len() <= movies.len()); + + for movie in filtered_items { + prop_assert!(matches_filter(movie)); + } + + for movie in movies.iter().filter(|movie| !filtered_items.contains(movie)) { + prop_assert!(!matches_filter(movie)); + } + } else { + prop_assert!(table.filtered_items.is_none()); + prop_assert!( + filter_term.is_empty() || movies.iter().all(|movie| !matches_filter(movie)) + ); } } } From 76c490f45bfe18846f6ec4b76b9ee992db620f2c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 14:42:54 -0600 Subject: [PATCH 141/170] fix: show N/A instead of zeros when a season, album or book has no statistics The season, album and book tables rendered 0/0 and 0.00 GB whenever the API omitted the statistics object for a row, presenting fabricated counts as real. The row colour already treated that case as indeterminate; the cell text now does too, rendering N/A for both cells, matching what these tables already do for a missing release date. Sonarr's season row is restructured from a destructure-with-default to the same Option-aware map_or_else shape lidarr and readarr use. The readarr test fixture book has no statistics, so its existing author details snapshots change from the fabricated zeros to N/A on exactly that row. --- src/ui/lidarr_ui/library/artist_details_ui.rs | 15 +++--- .../library/artist_details_ui_tests.rs | 13 +++++ ...s_ui_renders_album_without_statistics.snap | 52 +++++++++++++++++++ .../readarr_ui/library/author_details_ui.rs | 15 +++--- .../library/author_details_ui_tests.rs | 13 +++++ ...tests__author_details_AuthorDetails_0.snap | 2 +- ...ils_AutomaticallySearchAuthorPrompt_0.snap | 2 +- ...ts__author_details_SearchBooksError_0.snap | 2 +- ...t_tests__author_details_SearchBooks_0.snap | 2 +- ...r_details_UpdateAndScanAuthorPrompt_0.snap | 2 +- ...tic_search_prompt_over_author_details.snap | 2 +- ...ls_ui_renders_book_without_statistics.snap | 52 +++++++++++++++++++ ...nders_delete_book_over_author_details.snap | 2 +- ...e_and_scan_prompt_over_author_details.snap | 2 +- ...author_ui_renders_over_author_details.snap | 2 +- ...i_renders_author_details_over_library.snap | 2 +- src/ui/sonarr_ui/library/series_details_ui.rs | 33 ++++++------ .../library/series_details_ui_tests.rs | 13 +++++ ..._ui_renders_season_without_statistics.snap | 52 +++++++++++++++++++ 19 files changed, 238 insertions(+), 40 deletions(-) create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_album_without_statistics.snap create mode 100644 src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_without_statistics.snap create mode 100644 src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_season_without_statistics.snap diff --git a/src/ui/lidarr_ui/library/artist_details_ui.rs b/src/ui/lidarr_ui/library/artist_details_ui.rs index 168b61ee..fe79a7ce 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui.rs @@ -279,13 +279,16 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .release_date .map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string()); let track_count = album.statistics.as_ref().map_or_else( - || "0/0".to_owned(), + || "N/A".to_owned(), |s| format!("{}/{}", s.track_file_count, s.total_track_count), ); - let size = album - .statistics - .as_ref() - .map_or(0f64, |s| convert_to_gb(s.size_on_disk)); + let size = album.statistics.as_ref().map_or_else( + || "N/A".to_owned(), + |s| { + let size = convert_to_gb(s.size_on_disk); + format!("{size:.2} GB") + }, + ); let duration_mins = album.duration / 60000; decorate_album_row_with_style( @@ -297,7 +300,7 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(track_count), Cell::from(format!("{duration_mins} min")), Cell::from(release_date), - Cell::from(format!("{size:.2} GB")), + Cell::from(size), ]), ) }; diff --git a/src/ui/lidarr_ui/library/artist_details_ui_tests.rs b/src/ui/lidarr_ui/library/artist_details_ui_tests.rs index 3f3110e6..1652cea3 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui_tests.rs @@ -249,6 +249,19 @@ mod tests { ); } + #[test] + fn test_artist_details_ui_renders_album_without_statistics() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.albums.items[0].statistics = None; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_artist_details_ui_renders_delete_album_prompt_over_artist_details() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_album_without_statistics.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_album_without_statistics.snap new file mode 100644 index 00000000..9be4b0f3 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_album_without_statistics.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs +expression: output +--- + + + + ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Artist: Alex │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ + │Disambiguation: American pianist │ + │Type: Person │ + │Status: Continuing │ + │Genres: soundtrack │ + │Rating: 84% │ + │Path: /nfs/music/test-artist │ + │Quality Profile: Lossless │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │Albums: 1 │ + │Tracks: 12/15 │ + │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Albums │ History │ Manual Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Type Tracks Duration Release Date Size ││ + ││=> 🏷 Test Album Album N/A 0 min 2023-01-01 N/A ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/author_details_ui.rs b/src/ui/readarr_ui/library/author_details_ui.rs index 5521451f..4d38f2d9 100644 --- a/src/ui/readarr_ui/library/author_details_ui.rs +++ b/src/ui/readarr_ui/library/author_details_ui.rs @@ -275,13 +275,16 @@ fn draw_books_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .release_date .map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string()); let book_file_count = book.statistics.as_ref().map_or_else( - || "0/0".to_owned(), + || "N/A".to_owned(), |s| format!("{}/{}", s.book_file_count, s.total_book_count), ); - let size = book - .statistics - .as_ref() - .map_or(0f64, |s| convert_to_gb(s.size_on_disk)); + let size = book.statistics.as_ref().map_or_else( + || "N/A".to_owned(), + |s| { + let size = convert_to_gb(s.size_on_disk); + format!("{size:.2} GB") + }, + ); decorate_book_row_with_style( book, @@ -290,7 +293,7 @@ fn draw_books_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(book.title.to_string()), Cell::from(book_file_count), Cell::from(release_date), - Cell::from(format!("{size:.2} GB")), + Cell::from(size), ]), ) }; diff --git a/src/ui/readarr_ui/library/author_details_ui_tests.rs b/src/ui/readarr_ui/library/author_details_ui_tests.rs index e288f7a6..5ef55151 100644 --- a/src/ui/readarr_ui/library/author_details_ui_tests.rs +++ b/src/ui/readarr_ui/library/author_details_ui_tests.rs @@ -271,6 +271,19 @@ mod tests { ); } + #[test] + fn test_author_details_ui_renders_book_without_statistics() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.books.items[0].statistics = None; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_author_details_ui_renders_update_and_scan_prompt_over_author_details() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap index 1f6642b5..b72b374b 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││ + ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap index 9b45f8c0..c02e7efd 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││=> 🏷 Test Book │ │20 N/A ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap index bfb66384..00c61ca1 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││ + ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap index 3104d489..06cc5d02 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││ + ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap index 64dce43f..1785eab4 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││=> 🏷 Test Book │ │20 N/A ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap index 9b45f8c0..c02e7efd 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││=> 🏷 Test Book │ │20 N/A ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_without_statistics.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_without_statistics.snap new file mode 100644 index 00000000..b72b374b --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_without_statistics.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap index b0d064ba..aadf8deb 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │ Do you really want to delete the book: │ ││ ││─────────────────────────────────────────│ Test Book? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││=> 🏷 Test Book │ │20 N/A ││ ││ │ ╭───╮ │ ││ ││ │ Delete Book Files: │ ✔ │ │ ││ ││ │ ╰───╯ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap index 64dce43f..1785eab4 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 0.00 GB ││ + ││=> 🏷 Test Book │ │20 N/A ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap index eedd0a72..452caac0 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History ││ ╰───╯ │ ││ ││──────────────────│ ╭─────────────────────────────────────────────────╮ │────────────────────││ ││ Monitored │ Monitor New Books: │All Books ▼ │ │ ││ - ││=> 🏷 │ ╰─────────────────────────────────────────────────╯ │B ││ + ││=> 🏷 │ ╰─────────────────────────────────────────────────╯ │ ││ ││ │ ╭─────────────────────────────────────────────────╮ │ ││ ││ │ Quality Profile: │eBook ▼ │ │ ││ ││ │ ╰─────────────────────────────────────────────────╯ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap index adc0d386..bd1c5567 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││ + ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 489edae1..4170cf7d 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -10,7 +10,7 @@ use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use crate::app::App; use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_DETAILS_BLOCKS}; -use crate::models::sonarr_models::{Season, SeasonStatistics, SonarrHistoryItem}; +use crate::models::sonarr_models::{Season, SonarrHistoryItem}; 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::library::series_overview_ui::SeriesOverviewUi; @@ -229,29 +229,26 @@ 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 season_row_mapping = |season: &Season| { - let Season { - title, statistics, .. - } = season; - let SeasonStatistics { - episode_file_count, - episode_count, - size_on_disk, - .. - } = if let Some(stats) = statistics { - stats - } else { - &SeasonStatistics::default() - }; let season_monitored = if season.monitored { "🏷" } else { "" }; - let size = convert_to_gb(*size_on_disk); + let episode_count = season.statistics.as_ref().map_or_else( + || "N/A".to_owned(), + |s| format!("{}/{}", s.episode_file_count, s.episode_count), + ); + let size = season.statistics.as_ref().map_or_else( + || "N/A".to_owned(), + |s| { + let size = convert_to_gb(s.size_on_disk); + format!("{size:.2} GB") + }, + ); decorate_season_row_with_style( season, Row::new(vec![ Cell::from(season_monitored.to_owned()), - Cell::from(title.clone().unwrap_or_default()), - Cell::from(format!("{episode_file_count}/{episode_count}")), - Cell::from(format!("{size:.2} GB")), + Cell::from(season.title.clone().unwrap_or_default()), + Cell::from(episode_count), + Cell::from(size), ]), ) }; 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 302716a3..517a2649 100644 --- a/src/ui/sonarr_ui/library/series_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -239,6 +239,19 @@ mod tests { ); } + #[test] + fn test_series_details_ui_renders_season_without_statistics() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.seasons.items[0].statistics = None; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_series_details_ui_renders_season_details_over_series_details() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_season_without_statistics.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_season_without_statistics.snap new file mode 100644 index 00000000..bc7bf0f1 --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_season_without_statistics.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/sonarr_ui/library/series_details_ui_tests.rs +expression: output +--- + + + + ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Title: Test │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ + │Network: HBO │ + │Status: Continuing │ + │Genres: cool, family, fun │ + │Rating: 84% │ + │Year: 2022 │ + │Runtime: 63 minutes │ + │Path: /nfs/tv/Test │ + │Quality Profile: Bluray-1080p │ + │Language Profile: English │ + │Monitored: Yes │ + │Size on Disk: 59.51 GB │ + │ │ + │ │ + │ │ + │╭ Series Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Seasons │ History ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Season Episode Count Size on Disk ││ + ││=> 🏷 Season title N/A N/A ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ From 9d0973352ff34d08273805ef41b3791911daac58 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 14:50:27 -0600 Subject: [PATCH 142/170] feat(lidarr): highlight tracks whose album is downloading or awaiting import Lidarr queues whole albums rather than individual tracks, so a track row is styled from the queue entry whose albumId matches the track's albumId, mirroring the Sonarr episode row logic. Tracks that already have a file keep the downloaded style regardless of the queue. --- src/ui/lidarr_ui/library/album_details_ui.rs | 39 ++++++-- .../library/album_details_ui_tests.rs | 95 ++++++++++++++++++- 2 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/ui/lidarr_ui/library/album_details_ui.rs b/src/ui/lidarr_ui/library/album_details_ui.rs index 31628d86..e5dd920c 100644 --- a/src/ui/lidarr_ui/library/album_details_ui.rs +++ b/src/ui/lidarr_ui/library/album_details_ui.rs @@ -1,6 +1,8 @@ use crate::app::App; use crate::models::Route; -use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease, Track}; +use crate::models::lidarr_models::{ + DownloadRecord, DownloadStatus, LidarrHistoryItem, LidarrRelease, Track, +}; use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock}; use crate::ui::lidarr_ui::library::track_details_ui::TrackDetailsUi; use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details; @@ -150,6 +152,7 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .expect("Album details modal is unpopulated") .tracks, ); + let downloads_vec = &app.data.lidarr_data.downloads.items; let track_row_mapping = |track: &Track| { let Track { @@ -187,6 +190,7 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let duration_str = format!("{mins}:{secs:02}"); decorate_track_row_with_style( + downloads_vec, track, Row::new(vec![ Cell::from(track_number.clone()), @@ -221,12 +225,35 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } } -fn decorate_track_row_with_style<'a>(track: &Track, row: Row<'a>) -> Row<'a> { - if track.has_file { - row.downloaded() - } else { - row.missing() +fn decorate_track_row_with_style<'a>( + downloads_vec: &[DownloadRecord], + track: &Track, + row: Row<'a>, +) -> Row<'a> { + if !track.has_file { + let default_album_id = Number::from(-1i64); + if let Some(download) = downloads_vec.iter().find(|&download| { + download + .album_id + .as_ref() + .unwrap_or(&default_album_id) + .as_i64() + .unwrap() + == track.album_id + }) { + if download.status == DownloadStatus::Downloading { + return row.downloading(); + } + + if download.status == DownloadStatus::Completed { + return row.awaiting_import(); + } + } + + return row.missing(); } + + row.downloaded() } fn draw_album_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { diff --git a/src/ui/lidarr_ui/library/album_details_ui_tests.rs b/src/ui/lidarr_ui/library/album_details_ui_tests.rs index 260fb8af..a277027b 100644 --- a/src/ui/lidarr_ui/library/album_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/album_details_ui_tests.rs @@ -2,14 +2,16 @@ mod tests { use pretty_assertions::assert_eq; use ratatui::widgets::{Cell, Row}; + use serde_json::Number; use strum::IntoEnumIterator; use crate::app::App; - use crate::models::lidarr_models::Track; + use crate::models::lidarr_models::{DownloadRecord, DownloadStatus, Track}; use crate::models::servarr_data::lidarr::lidarr_data::{ ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock, TRACK_DETAILS_BLOCKS, }; use crate::models::stateful_table::StatefulTable; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::download_record; use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::album_details_ui::{ AlbumDetailsUi, decorate_track_row_with_style, @@ -39,7 +41,7 @@ mod tests { }; let row = Row::new(vec![Cell::from("test".to_owned())]); - let style = decorate_track_row_with_style(&track, row.clone()); + let style = decorate_track_row_with_style(&[], &track, row.clone()); assert_eq!(style, row.downloaded()); } @@ -52,11 +54,89 @@ mod tests { }; let row = Row::new(vec![Cell::from("test".to_owned())]); - let style = decorate_track_row_with_style(&track, row.clone()); + let style = decorate_track_row_with_style(&[], &track, row.clone()); assert_eq!(style, row.missing()); } + #[test] + fn test_decorate_track_row_with_style_downloading_when_track_has_no_file_and_album_is_downloading() + { + let downloads_vec = vec![album_download(7, DownloadStatus::Downloading)]; + let track = Track { + has_file: false, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.downloading()); + } + + #[test] + fn test_decorate_track_row_with_style_awaiting_import_when_track_has_no_file_and_album_download_is_completed() + { + let downloads_vec = vec![album_download(7, DownloadStatus::Completed)]; + let track = Track { + has_file: false, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.awaiting_import()); + } + + #[test] + fn test_decorate_track_row_with_style_missing_when_album_download_is_neither_downloading_nor_completed() + { + let downloads_vec = vec![album_download(7, DownloadStatus::Queued)]; + let track = Track { + has_file: false, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_track_row_with_style_missing_when_only_a_different_album_is_downloading() { + let downloads_vec = vec![album_download(8, DownloadStatus::Downloading)]; + let track = Track { + has_file: false, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_track_row_with_style_downloaded_when_track_has_file_and_album_is_downloading() { + let downloads_vec = vec![album_download(7, DownloadStatus::Downloading)]; + let track = Track { + has_file: true, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.downloaded()); + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; @@ -177,4 +257,13 @@ mod tests { insta::assert_snapshot!(output); } } + + fn album_download(album_id: i64, status: DownloadStatus) -> DownloadRecord { + DownloadRecord { + status, + album_id: Some(Number::from(album_id)), + artist_id: Some(Number::from(3i64)), + ..download_record() + } + } } From a68de41025c30b6ed6bdd670ade8c649442099b5 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 15:08:52 -0600 Subject: [PATCH 143/170] fix: fall back to the default when a servarr returns an unknown enum value A Servarr build can emit an enum value this app has no name for, either as a string it does not recognise or as a bare integer for a value the build itself cannot name. Until now any such value in a response failed the whole payload, so one unexpected artist status, download status, task name, series type, minimum availability, credit type, or security setting emptied the entire view with "Failed to parse response!". Every enum-typed field on a struct the app only ever deserializes and displays now uses from_json_or_default, so an unrecognised value degrades to that enum's default and the rest of the record is kept. Known values still map exactly as before and serialization is untouched. Fields on request bodies (Add*Body, Add*Options, Edit*Params, the root folder bodies) are deliberately left alone: those structs are only ever serialized, so the attribute would be meaningless there. No candidate struct is round-tripped: every edit and toggle in the app re-fetches the raw JSON value and patches it by key, so the fallback can never be written back to a server as a real setting. The one Option field, the security config's authentication_required, also carries serde(default), because deserialize_with on an Option turns a missing key into a hard error. An unknown value there yields None. Verified by mutation. Removing the attribute from one field per tree fails exactly that field's test and nothing else. Dropping default from the Option field fails only the missing-key test with "missing field authenticationRequired"; dropping deserialize_with from it fails only the unknown-value test. All 21 new fallback tests fail against the unguarded code with "invalid type: integer 2, expected string or map". --- src/models/lidarr_models.rs | 5 ++ src/models/lidarr_models_tests.rs | 110 ++++++++++++++++++++++++++- src/models/radarr_models.rs | 8 +- src/models/radarr_models_tests.rs | 90 +++++++++++++++++++++- src/models/readarr_models.rs | 5 ++ src/models/readarr_models_tests.rs | 111 +++++++++++++++++++++++++++ src/models/servarr_models.rs | 3 + src/models/servarr_models_tests.rs | 118 ++++++++++++++++++++++++++++- src/models/sonarr_models.rs | 4 + src/models/sonarr_models_tests.rs | 81 ++++++++++++++++++++ 10 files changed, 529 insertions(+), 6 deletions(-) diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 74fe95b1..3d22bab6 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -27,6 +27,7 @@ pub struct Artist { pub id: i64, pub artist_name: HorizontallyScrollableText, pub foreign_artist_id: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: ArtistStatus, pub overview: Option, pub artist_type: Option, @@ -38,6 +39,7 @@ pub struct Artist { #[serde(deserialize_with = "super::from_i64")] pub metadata_profile_id: i64, pub monitored: bool, + #[serde(deserialize_with = "super::from_json_or_default")] pub monitor_new_items: NewItemMonitorType, pub genres: Vec, pub tags: Vec, @@ -161,6 +163,7 @@ pub enum MonitorType { #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: DownloadStatus, #[serde(deserialize_with = "super::from_i64")] pub id: i64, @@ -219,6 +222,7 @@ pub struct DownloadsResponse { pub struct AddArtistSearchResult { pub foreign_artist_id: String, pub artist_name: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: ArtistStatus, pub overview: Option, pub artist_type: Option, @@ -414,6 +418,7 @@ pub struct LidarrHistoryItem { #[serde(rename_all = "camelCase")] pub struct LidarrTask { pub name: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub task_name: LidarrTaskName, #[serde(deserialize_with = "super::from_i64")] pub interval: i64, diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 618d6f5f..66a1f083 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -2,13 +2,14 @@ mod tests { use chrono::Utc; use pretty_assertions::{assert_eq, assert_str_eq}; + use serde::de::DeserializeOwned; use serde_json::{Value, json}; use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, - LidarrHistoryWrapper, LidarrRelease, LidarrTask, MediaInfo, Member, MonitorType, - NewItemMonitorType, Track, TrackFile, + LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, + MonitorType, NewItemMonitorType, Track, TrackFile, }; use crate::models::servarr_models::{ DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, @@ -19,6 +20,9 @@ mod tests { Serdeable, lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings}, }; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ + ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON, download_record, task, + }; #[test] fn test_artist_status_default() { @@ -946,6 +950,108 @@ mod tests { assert_none!(&search_result.ratings); } + #[test] + fn test_artist_status_deserialization_falls_back_to_default() { + let expected = Artist { + status: ArtistStatus::default(), + ..serde_json::from_str(ARTIST_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(ARTIST_JSON, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(ARTIST_JSON, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_artist_monitor_new_items_deserialization_falls_back_to_default() { + let expected = Artist { + monitor_new_items: NewItemMonitorType::default(), + ..serde_json::from_str(ARTIST_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(ARTIST_JSON, "monitorNewItems", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(ARTIST_JSON, "monitorNewItems", json!("notAThing")), + expected + ); + } + + #[test] + fn test_add_artist_search_result_status_deserialization_falls_back_to_default() { + let expected = AddArtistSearchResult { + status: ArtistStatus::default(), + ..serde_json::from_str(ADD_ARTIST_SEARCH_RESULT_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::( + ADD_ARTIST_SEARCH_RESULT_JSON, + "status", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + ADD_ARTIST_SEARCH_RESULT_JSON, + "status", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_download_record_status_deserialization_falls_back_to_default() { + let download_record_json = serde_json::to_string(&download_record()).unwrap(); + let expected = DownloadRecord { + status: DownloadStatus::default(), + ..download_record() + }; + + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_lidarr_task_task_name_deserialization_falls_back_to_default() { + let task_json = serde_json::to_string(&task()).unwrap(); + let expected = LidarrTask { + task_name: LidarrTaskName::default(), + ..task() + }; + + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!("notAThing")), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + fn deserialize_history_event_type(event_type: Value) -> LidarrHistoryEventType { let history_item_json = json!({ "id": 1, diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index c71fe71f..69481985 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -98,6 +98,7 @@ pub struct Collection { pub search_on_add: bool, pub monitored: bool, pub overview: Option, + #[serde(deserialize_with = "super::from_json_or_default")] pub minimum_availability: MinimumAvailability, #[serde(deserialize_with = "super::from_i64")] pub quality_profile_id: i64, @@ -126,7 +127,10 @@ pub struct Credit { pub character: Option, pub department: Option, pub job: Option, - #[serde(rename(deserialize = "type"))] + #[serde( + rename(deserialize = "type"), + deserialize_with = "super::from_json_or_default" + )] pub credit_type: CreditType, } @@ -316,6 +320,7 @@ pub struct Movie { pub tmdb_id: i64, #[serde(deserialize_with = "super::from_i64")] pub quality_profile_id: i64, + #[serde(deserialize_with = "super::from_json_or_default")] pub minimum_availability: MinimumAvailability, pub certification: Option, pub tags: Vec, @@ -469,6 +474,7 @@ pub struct RadarrHistoryItem { #[serde(rename_all = "camelCase")] pub struct RadarrTask { pub name: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub task_name: RadarrTaskName, #[serde(deserialize_with = "super::from_i64")] pub interval: i64, diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index a114a17d..9a7e2ec2 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; + use serde::de::DeserializeOwned; use serde_json::{Value, json}; use crate::models::radarr_models::{ @@ -9,8 +10,8 @@ mod tests { use crate::models::{ Serdeable, radarr_models::{ - AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, - DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, + AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, CreditType, + DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease, RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update, }, @@ -18,6 +19,9 @@ mod tests { HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, SystemStatus, }, }; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::{ + MOVIE_JSON, collection, crew_credit, task, + }; #[test] fn test_task_name_display() { @@ -614,6 +618,88 @@ mod tests { ); } + #[test] + fn test_movie_minimum_availability_deserialization_falls_back_to_default() { + let expected = Movie { + minimum_availability: MinimumAvailability::default(), + ..serde_json::from_str(MOVIE_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(MOVIE_JSON, "minimumAvailability", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(MOVIE_JSON, "minimumAvailability", json!("notAThing")), + expected + ); + } + + #[test] + fn test_collection_minimum_availability_deserialization_falls_back_to_default() { + let collection_json = serde_json::to_string(&collection()).unwrap(); + let expected = Collection { + minimum_availability: MinimumAvailability::default(), + ..collection() + }; + + assert_eq!( + deserialize_with_field::(&collection_json, "minimumAvailability", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::( + &collection_json, + "minimumAvailability", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_credit_credit_type_deserialization_falls_back_to_default() { + let credit_json = serde_json::to_string(&crew_credit()).unwrap(); + let expected = Credit { + credit_type: CreditType::default(), + ..crew_credit() + }; + + assert_eq!( + deserialize_with_field::(&credit_json, "type", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&credit_json, "type", json!("notAThing")), + expected + ); + } + + #[test] + fn test_radarr_task_task_name_deserialization_falls_back_to_default() { + let task_json = serde_json::to_string(&task()).unwrap(); + let expected = RadarrTask { + task_name: RadarrTaskName::default(), + ..task() + }; + + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!("notAThing")), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + fn deserialize_history_event_type(event_type: Value) -> RadarrHistoryEventType { let history_item_json = json!({ "id": 1, diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index 17dc3257..c924b19a 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -27,6 +27,7 @@ pub struct Author { pub id: i64, pub author_name: HorizontallyScrollableText, pub foreign_author_id: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: AuthorStatus, pub overview: Option, pub author_type: Option, @@ -38,6 +39,7 @@ pub struct Author { #[serde(deserialize_with = "super::from_i64")] pub metadata_profile_id: i64, pub monitored: bool, + #[serde(deserialize_with = "super::from_json_or_default")] pub monitor_new_items: NewItemMonitorType, pub genres: Vec, pub tags: Vec, @@ -152,6 +154,7 @@ pub enum MonitorType { #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: DownloadStatus, #[serde(deserialize_with = "super::from_i64")] pub id: i64, @@ -210,6 +213,7 @@ pub struct DownloadsResponse { pub struct AddAuthorSearchResult { pub foreign_author_id: String, pub author_name: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: AuthorStatus, #[serde(default)] pub ended: bool, @@ -423,6 +427,7 @@ pub struct ReadarrHistoryItem { #[serde(rename_all = "camelCase")] pub struct ReadarrTask { pub name: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub task_name: ReadarrTaskName, #[serde(deserialize_with = "super::from_i64")] pub interval: i64, diff --git a/src/models/readarr_models_tests.rs b/src/models/readarr_models_tests.rs index 20ffb5b5..867495c4 100644 --- a/src/models/readarr_models_tests.rs +++ b/src/models/readarr_models_tests.rs @@ -2,6 +2,7 @@ mod tests { use chrono::Utc; use pretty_assertions::{assert_eq, assert_str_eq}; + use serde::de::DeserializeOwned; use serde_json::{Value, json}; use crate::models::readarr_models::{ @@ -19,6 +20,9 @@ mod tests { Serdeable, readarr_models::{Author, AuthorStatistics, AuthorStatus, Ratings, ReadarrSerdeable}, }; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, download_record, + }; #[test] fn test_author_status_default() { @@ -1140,6 +1144,113 @@ mod tests { assert_none!(&search_result.ratings); } + #[test] + fn test_author_status_deserialization_falls_back_to_default() { + let expected = Author { + status: AuthorStatus::default(), + ..serde_json::from_str(AUTHOR_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(AUTHOR_JSON, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(AUTHOR_JSON, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_author_monitor_new_items_deserialization_falls_back_to_default() { + let expected = Author { + monitor_new_items: NewItemMonitorType::default(), + ..serde_json::from_str(AUTHOR_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(AUTHOR_JSON, "monitorNewItems", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(AUTHOR_JSON, "monitorNewItems", json!("notAThing")), + expected + ); + } + + #[test] + fn test_add_author_search_result_status_deserialization_falls_back_to_default() { + let expected = AddAuthorSearchResult { + status: AuthorStatus::default(), + ..serde_json::from_str(ADD_AUTHOR_SEARCH_RESULT_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::( + ADD_AUTHOR_SEARCH_RESULT_JSON, + "status", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + ADD_AUTHOR_SEARCH_RESULT_JSON, + "status", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_download_record_status_deserialization_falls_back_to_default() { + let download_record_json = serde_json::to_string(&download_record()).unwrap(); + let expected = DownloadRecord { + status: DownloadStatus::default(), + ..download_record() + }; + + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_readarr_task_task_name_deserialization_falls_back_to_default() { + let task = ReadarrTask { + name: "Backup".to_owned(), + task_name: ReadarrTaskName::Backup, + ..ReadarrTask::default() + }; + let task_json = serde_json::to_string(&task).unwrap(); + let expected = ReadarrTask { + task_name: ReadarrTaskName::default(), + ..task + }; + + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!("notAThing")), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + fn deserialize_history_event_type(event_type: Value) -> ReadarrHistoryEventType { let history_item_json = json!({ "id": 1, diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index ef9d09a3..1d87f8c2 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -315,7 +315,9 @@ pub struct RootFolder { #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SecurityConfig { + #[serde(deserialize_with = "super::from_json_or_default")] pub authentication_method: AuthenticationMethod, + #[serde(default, deserialize_with = "super::from_json_or_default")] #[serde(skip_serializing_if = "Option::is_none")] pub authentication_required: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -323,6 +325,7 @@ pub struct SecurityConfig { #[serde(skip_serializing_if = "Option::is_none")] pub password: Option, pub api_key: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub certificate_validation: CertificateValidation, } diff --git a/src/models/servarr_models_tests.rs b/src/models/servarr_models_tests.rs index 52dd4f2f..98fd176d 100644 --- a/src/models/servarr_models_tests.rs +++ b/src/models/servarr_models_tests.rs @@ -1,10 +1,12 @@ #[cfg(test)] mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; + use serde::de::DeserializeOwned; + use serde_json::{Value, json}; use crate::models::servarr_models::{ AuthenticationMethod, AuthenticationRequired, CertificateValidation, Indexer, QualityProfile, - Update, UpdateChanges, + SecurityConfig, Update, UpdateChanges, }; #[test] @@ -90,4 +92,118 @@ mod tests { } ); } + + #[test] + fn test_security_config_authentication_method_deserialization_falls_back_to_default() { + let security_config_json = serde_json::to_string(&security_config()).unwrap(); + let expected = SecurityConfig { + authentication_method: AuthenticationMethod::default(), + ..security_config() + }; + + assert_eq!( + deserialize_with_field::( + &security_config_json, + "authenticationMethod", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + &security_config_json, + "authenticationMethod", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_security_config_authentication_required_deserialization_falls_back_to_none() { + let security_config_json = serde_json::to_string(&security_config()).unwrap(); + let expected = SecurityConfig { + authentication_required: None, + ..security_config() + }; + + assert_eq!( + deserialize_with_field::( + &security_config_json, + "authenticationRequired", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + &security_config_json, + "authenticationRequired", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_security_config_deserializes_when_authentication_required_is_absent() { + let mut security_config_json = serde_json::to_value(security_config()).unwrap(); + security_config_json + .as_object_mut() + .unwrap() + .remove("authenticationRequired"); + let expected = SecurityConfig { + authentication_required: None, + ..security_config() + }; + + let deserialized = serde_json::from_value::(security_config_json); + + assert_ok!(&deserialized); + assert_eq!(deserialized.unwrap(), expected); + } + + #[test] + fn test_security_config_certificate_validation_deserialization_falls_back_to_default() { + let security_config_json = serde_json::to_string(&security_config()).unwrap(); + let expected = SecurityConfig { + certificate_validation: CertificateValidation::default(), + ..security_config() + }; + + assert_eq!( + deserialize_with_field::( + &security_config_json, + "certificateValidation", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + &security_config_json, + "certificateValidation", + json!("notAThing") + ), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + + fn security_config() -> SecurityConfig { + SecurityConfig { + authentication_method: AuthenticationMethod::Forms, + authentication_required: Some(AuthenticationRequired::Enabled), + username: Some("admin".to_owned()), + password: Some("password".to_owned()), + api_key: "test-api-key".to_owned(), + certificate_validation: CertificateValidation::Disabled, + } + } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 11ee02d2..aca2c844 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -112,6 +112,7 @@ pub struct DeleteSeriesParams { #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: DownloadStatus, #[serde(deserialize_with = "super::from_i64")] pub id: i64, @@ -314,12 +315,14 @@ pub struct Series { #[serde(deserialize_with = "super::from_i64")] pub year: i64, pub monitored: bool, + #[serde(deserialize_with = "super::from_json_or_default")] pub series_type: SeriesType, pub path: String, pub genres: Vec, pub tags: Vec, pub ratings: Rating, pub ended: bool, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: SeriesStatus, pub overview: Option, pub network: Option, @@ -556,6 +559,7 @@ pub struct SonarrReleaseDownloadBody { #[serde(rename_all = "camelCase")] pub struct SonarrTask { pub name: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub task_name: SonarrTaskName, #[serde(deserialize_with = "super::from_i64")] pub interval: i64, diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 8e82559a..286cb714 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; + use serde::de::DeserializeOwned; use serde_json::{Value, json}; use crate::models::{ @@ -17,6 +18,9 @@ mod tests { SonarrTaskName, }, }; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{ + SERIES_JSON, download_record, task, + }; #[test] fn test_episode_display() { @@ -698,6 +702,83 @@ mod tests { ); } + #[test] + fn test_series_series_type_deserialization_falls_back_to_default() { + let expected = Series { + series_type: SeriesType::default(), + ..serde_json::from_str(SERIES_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(SERIES_JSON, "seriesType", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(SERIES_JSON, "seriesType", json!("notAThing")), + expected + ); + } + + #[test] + fn test_series_status_deserialization_falls_back_to_default() { + let expected = Series { + status: SeriesStatus::default(), + ..serde_json::from_str(SERIES_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(SERIES_JSON, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(SERIES_JSON, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_download_record_status_deserialization_falls_back_to_default() { + let download_record_json = serde_json::to_string(&download_record()).unwrap(); + let expected = DownloadRecord { + status: DownloadStatus::default(), + ..download_record() + }; + + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_sonarr_task_task_name_deserialization_falls_back_to_default() { + let task_json = serde_json::to_string(&task()).unwrap(); + let expected = SonarrTask { + task_name: SonarrTaskName::default(), + ..task() + }; + + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!("notAThing")), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + fn deserialize_history_event_type(event_type: Value) -> SonarrHistoryEventType { let history_item_json = json!({ "id": 1, From 289abbd8eb376820a87f31ea485e357a6d413fa2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 15:19:16 -0600 Subject: [PATCH 144/170] test(readarr): populate the book fixture with statistics The shared Book fixture left statistics as None, so every author details snapshot rendered the missing-statistics N/A cells and the populated branch of the Files and Size columns was only exercised by one test. Give the fixture distinct, non-default counts and a size that formats to a value different from the dedicated statistics test. --- src/models/servarr_data/readarr/readarr_data.rs | 10 ++++++++-- ...snapshot_tests__author_details_AuthorDetails_0.snap | 2 +- ...thor_details_AutomaticallySearchAuthorPrompt_0.snap | 2 +- ...pshot_tests__author_details_SearchBooksError_0.snap | 2 +- ...__snapshot_tests__author_details_SearchBooks_0.snap | 2 +- ...ts__author_details_UpdateAndScanAuthorPrompt_0.snap | 2 +- ...rs_automatic_search_prompt_over_author_details.snap | 2 +- ...ils_ui_renders_delete_book_over_author_details.snap | 2 +- ...ers_update_and_scan_prompt_over_author_details.snap | 2 +- ...ts__edit_author_ui_renders_over_author_details.snap | 2 +- ...library_ui_renders_author_details_over_library.snap | 2 +- 11 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index 62154f37..ba2cc4c0 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -31,8 +31,8 @@ use strum::EnumIter; use { super::modals::EditionDetailsModal, crate::models::readarr_models::{ - AuthorStatus, BookFile, DownloadStatus, Edition, MediaInfo, MonitorType, NewItemMonitorType, - Ratings, ReadarrHistoryEventType, ReadarrTaskName, + AuthorStatus, BookFile, BookStatistics, DownloadStatus, Edition, MediaInfo, MonitorType, + NewItemMonitorType, Ratings, ReadarrHistoryEventType, ReadarrTaskName, }, crate::models::servarr_models::{IndexerField, Quality, QualityWrapper}, crate::models::stateful_table::SortOption, @@ -292,6 +292,12 @@ impl ReadarrData<'_> { any_edition_ok: true, page_count: Some(288), release_date: Some(date), + statistics: Some(BookStatistics { + book_file_count: 3, + total_book_count: 7, + size_on_disk: 2469606195, + percent_of_books: 42.86, + }), ..Book::default() }; let readarr_history_item = ReadarrHistoryItem { diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap index b72b374b..e6343be1 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap index c02e7efd..b2da3535 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 N/A ││ + ││=> 🏷 Test Book │ │20 2.30 GB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap index 00c61ca1..61b74938 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap index 06cc5d02..69d3bec3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap index 1785eab4..f6ed5de7 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 N/A ││ + ││=> 🏷 Test Book │ │20 2.30 GB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap index c02e7efd..b2da3535 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 N/A ││ + ││=> 🏷 Test Book │ │20 2.30 GB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap index aadf8deb..97cb8bbc 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │ Do you really want to delete the book: │ ││ ││─────────────────────────────────────────│ Test Book? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 N/A ││ + ││=> 🏷 Test Book │ │20 2.30 GB ││ ││ │ ╭───╮ │ ││ ││ │ Delete Book Files: │ ✔ │ │ ││ ││ │ ╰───╯ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap index 1785eab4..f6ed5de7 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 N/A ││ + ││=> 🏷 Test Book │ │20 2.30 GB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap index 452caac0..eedd0a72 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History ││ ╰───╯ │ ││ ││──────────────────│ ╭─────────────────────────────────────────────────╮ │────────────────────││ ││ Monitored │ Monitor New Books: │All Books ▼ │ │ ││ - ││=> 🏷 │ ╰─────────────────────────────────────────────────╯ │ ││ + ││=> 🏷 │ ╰─────────────────────────────────────────────────╯ │B ││ ││ │ ╭─────────────────────────────────────────────────╮ │ ││ ││ │ Quality Profile: │eBook ▼ │ │ ││ ││ │ ╰─────────────────────────────────────────────────╯ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap index bd1c5567..37368057 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││ ││ ││ ││ ││ ││ ││ From e9c323c4e86e421d9932a2b05efdfebbfd0746e7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 15:30:16 -0600 Subject: [PATCH 145/170] test(sonarr): cover the episode row style branches decorate_with_row_style had no unit tests, so every one of its seven return sites could be swapped for another style without a failure. Add one test per branch, including the ordering cases where the download match precedes the monitored guard and the monitored guard precedes the air date check, using a local download fixture with distinct episode, other-episode, and download ids. --- .../library/season_details_ui_tests.rs | 249 +++++++++++++++++- 1 file changed, 248 insertions(+), 1 deletion(-) 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 26cd5030..f5754713 100644 --- a/src/ui/sonarr_ui/library/season_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/season_details_ui_tests.rs @@ -1,14 +1,23 @@ #[cfg(test)] mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + use serde_json::Number; use strum::IntoEnumIterator; use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, }; + use crate::models::sonarr_models::{DownloadRecord, DownloadStatus, Episode}; use crate::models::stateful_table::StatefulTable; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::download_record; use crate::ui::DrawUi; - use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; + use crate::ui::sonarr_ui::library::season_details_ui::{ + SeasonDetailsUi, decorate_with_row_style, + }; + use crate::ui::styles::ManagarrStyle; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -25,6 +34,235 @@ mod tests { }); } + #[test] + fn test_decorate_with_row_style_downloaded_when_episode_has_file() { + let episode = Episode { + id: 5, + has_file: true, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_with_row_style_unmonitored_when_episode_has_file_and_is_unmonitored() { + let episode = Episode { + id: 5, + has_file: true, + monitored: false, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_episode_has_no_file() { + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_unmonitored_missing_when_episode_has_no_file_and_is_unmonitored() + { + let episode = Episode { + id: 5, + has_file: false, + monitored: false, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.unmonitored_missing()); + } + + #[test] + fn test_decorate_with_row_style_unreleased_when_episode_has_no_file_and_air_date_is_in_the_future() + { + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + air_date_utc: Some(Utc::now() + Duration::days(1)), + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_episode_has_no_file_and_air_date_is_in_the_past() { + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + air_date_utc: Some(Utc::now() - Duration::days(1)), + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_unmonitored_missing_when_unmonitored_episode_has_no_file_and_air_date_is_in_the_future() + { + let episode = Episode { + id: 5, + has_file: false, + monitored: false, + air_date_utc: Some(Utc::now() + Duration::days(1)), + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.unmonitored_missing()); + } + + #[test] + fn test_decorate_with_row_style_downloading_when_episode_has_no_file_and_is_downloading() { + let downloads_vec = vec![episode_download(5, DownloadStatus::Downloading)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.downloading()); + } + + #[test] + fn test_decorate_with_row_style_awaiting_import_when_episode_has_no_file_and_download_is_completed() + { + let downloads_vec = vec![episode_download(5, DownloadStatus::Completed)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.awaiting_import()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_episode_download_is_neither_downloading_nor_completed() + { + let downloads_vec = vec![episode_download(5, DownloadStatus::Queued)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_only_a_different_episode_is_downloading() { + let downloads_vec = vec![episode_download(6, DownloadStatus::Downloading)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_download_has_no_episode_id() { + let downloads_vec = vec![DownloadRecord { + episode_id: None, + ..episode_download(5, DownloadStatus::Downloading) + }]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_downloading_when_unmonitored_episode_has_no_file_and_is_downloading() + { + let downloads_vec = vec![episode_download(5, DownloadStatus::Downloading)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: false, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.downloading()); + } + + #[test] + fn test_decorate_with_row_style_downloaded_when_episode_has_file_and_is_downloading() { + let downloads_vec = vec![episode_download(5, DownloadStatus::Downloading)]; + let episode = Episode { + id: 5, + has_file: true, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.downloaded()); + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; @@ -145,4 +383,13 @@ mod tests { insta::assert_snapshot!(output); } } + + fn episode_download(episode_id: i64, status: DownloadStatus) -> DownloadRecord { + DownloadRecord { + status, + id: 9, + episode_id: Some(Number::from(episode_id)), + ..download_record() + } + } } From 25da032f02c9121995607d0b6993c3139449df08 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 15:36:27 -0600 Subject: [PATCH 146/170] test(lidarr): give the download record fixture distinct album and artist ids The shared download record held id, album id, and artist id all equal to 1, so reading the wrong one of the three could not fail any test that uses the fixture. Move artist id to 3 and id to 9, keeping album id at 1 so the fully populated album view still shows its track as downloading, and update the three handler assertions that named the old id. --- .../lidarr_handlers/downloads/downloads_handler_tests.rs | 6 +++--- src/network/lidarr_network/lidarr_network_test_utils.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs index 9c65988e..b36ab646 100644 --- a/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs @@ -141,7 +141,7 @@ mod tests { #[case( ActiveLidarrBlock::Downloads, ActiveLidarrBlock::DeleteDownloadPrompt, - LidarrEvent::DeleteDownload(1) + LidarrEvent::DeleteDownload(9) )] #[case( ActiveLidarrBlock::Downloads, @@ -338,7 +338,7 @@ mod tests { #[case( ActiveLidarrBlock::Downloads, ActiveLidarrBlock::DeleteDownloadPrompt, - LidarrEvent::DeleteDownload(1) + LidarrEvent::DeleteDownload(9) )] #[case( ActiveLidarrBlock::Downloads, @@ -423,7 +423,7 @@ mod tests { ) .extract_download_id(); - assert_eq!(download_id, 1); + assert_eq!(download_id, 9); } #[test] diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index d50a5ed5..a841f9b9 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -184,9 +184,9 @@ pub mod test_utils { DownloadRecord { title: "Test download title".to_owned(), status: DownloadStatus::Downloading, - id: 1, + id: 9, album_id: Some(Number::from(1i64)), - artist_id: Some(Number::from(1i64)), + artist_id: Some(Number::from(3i64)), size: 3543348019f64, sizeleft: 1771674009f64, output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")), From 7b6d5953978b44a1cb55b120286d3786349fbe9c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 15:46:34 -0600 Subject: [PATCH 147/170] fix(radarr): model the download status as a DownloadStatus enum Radarr was the only tree still holding DownloadRecord.status as a bare String and comparing it against literals. It now carries its own DownloadStatus enum, shaped exactly like the Sonarr, Lidarr and Readarr copies, and is guarded with from_json_or_default so an unrecognised status degrades to Unknown instead of failing the whole queue fetch. The row-style decorator and get_movie_status compare against the enum variants. The status cell is never rendered in the Radarr tree, so no snapshot changes; the CLI JSON output is unchanged for every real value since the enum serializes to the same camelCase strings. --- .../radarr_handler_test_utils.rs | 6 +- src/models/radarr_models.rs | 33 +++++++++- src/models/radarr_models_tests.rs | 64 +++++++++++++++++-- src/network/radarr_network/library/mod.rs | 6 +- .../library/radarr_library_network_tests.rs | 9 +-- .../radarr_network_test_utils.rs | 9 +-- src/ui/radarr_ui/mod.rs | 6 +- src/ui/radarr_ui/radarr_ui_tests.rs | 22 +++++-- 8 files changed, 126 insertions(+), 29 deletions(-) diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index 77a0382e..9eabb627 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -4,8 +4,8 @@ pub(in crate::handlers::radarr_handlers) mod utils { use crate::models::HorizontallyScrollableText; use crate::models::radarr_models::{ AddMovieBody, AddMovieOptions, AddMovieSearchResult, Collection, CollectionMovie, - DownloadRecord, IndexerSettings, MediaInfo, MinimumAvailability, Movie, MovieCollection, - MovieFile, MovieMonitor, RadarrRelease, Rating, RatingsList, + DownloadRecord, DownloadStatus, IndexerSettings, MediaInfo, MinimumAvailability, Movie, + MovieCollection, MovieFile, MovieMonitor, RadarrRelease, Rating, RatingsList, }; use crate::models::servarr_models::{ Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, @@ -407,7 +407,7 @@ pub(in crate::handlers::radarr_handlers) mod utils { pub fn download_record() -> DownloadRecord { DownloadRecord { title: "Test Download Title".to_owned(), - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, id: 1, movie_id: 1, size: 3543348019, diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 69481985..9e1a5984 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -154,7 +154,8 @@ pub struct DeleteMovieParams { #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, - pub status: String, + #[serde(deserialize_with = "super::from_json_or_default")] + pub status: DownloadStatus, #[serde(deserialize_with = "super::from_i64")] pub id: i64, #[serde(deserialize_with = "super::from_i64")] @@ -169,6 +170,36 @@ pub struct DownloadRecord { pub download_client: String, } +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum DownloadStatus { + #[default] + Unknown, + Queued, + Paused, + Downloading, + Completed, + Failed, + Warning, + Delay, + #[display_style(name = "Download Client Unavailable")] + DownloadClientUnavailable, + Fallback, +} + #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 9a7e2ec2..430ce115 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -11,16 +11,16 @@ mod tests { Serdeable, radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, CreditType, - DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, - MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease, - RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update, + DiskSpace, DownloadRecord, DownloadStatus, DownloadsResponse, Indexer, IndexerSettings, + IndexerTestResult, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, + QualityProfile, RadarrRelease, RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update, }, servarr_models::{ HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, SystemStatus, }, }; use crate::network::radarr_network::radarr_network_test_utils::test_utils::{ - MOVIE_JSON, collection, crew_credit, task, + MOVIE_JSON, collection, crew_credit, download_record, task, }; #[test] @@ -70,6 +70,40 @@ mod tests { assert_str_eq!(MovieMonitor::None.to_display_str(), "None"); } + #[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_radarr_history_event_type_display() { assert_str_eq!(RadarrHistoryEventType::Unknown.to_string(), "unknown"); @@ -230,7 +264,7 @@ mod tests { fn test_download_record_default_indexer_value() { let json = r#"{ "title": "test", - "status": "test", + "status": "downloading", "id": 0, "movieId": 0, "size": 0, @@ -239,7 +273,7 @@ mod tests { }"#; let expected_record = DownloadRecord { title: "test".to_owned(), - status: "test".to_owned(), + status: DownloadStatus::Downloading, id: 0, movie_id: 0, size: 0, @@ -693,6 +727,24 @@ mod tests { ); } + #[test] + fn test_download_record_status_deserialization_falls_back_to_default() { + let download_record_json = serde_json::to_string(&download_record()).unwrap(); + let expected = DownloadRecord { + status: DownloadStatus::default(), + ..download_record() + }; + + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!("notAThing")), + expected + ); + } + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { let mut fixture_json: Value = serde_json::from_str(json).unwrap(); fixture_json[field] = value; diff --git a/src/network/radarr_network/library/mod.rs b/src/network/radarr_network/library/mod.rs index e373c58a..cbc1027d 100644 --- a/src/network/radarr_network/library/mod.rs +++ b/src/network/radarr_network/library/mod.rs @@ -1,6 +1,6 @@ use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, Credit, CreditType, DeleteMovieParams, DownloadRecord, - EditMovieParams, Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease, + DownloadStatus, EditMovieParams, Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease, RadarrReleaseDownloadBody, }; use crate::models::servarr_data::Notification; @@ -664,11 +664,11 @@ pub(in crate::network::radarr_network::library) fn get_movie_status( .iter() .find(|&download| download.movie_id == movie_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/radarr_network/library/radarr_library_network_tests.rs b/src/network/radarr_network/library/radarr_library_network_tests.rs index 941f0108..ca4f44d4 100644 --- a/src/network/radarr_network/library/radarr_library_network_tests.rs +++ b/src/network/radarr_network/library/radarr_library_network_tests.rs @@ -1,8 +1,9 @@ #[cfg(test)] mod tests { use crate::models::radarr_models::{ - AddMovieBody, AddMovieOptions, Credit, DeleteMovieParams, DownloadRecord, EditMovieParams, - MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, RadarrReleaseDownloadBody, + AddMovieBody, AddMovieOptions, Credit, DeleteMovieParams, DownloadRecord, DownloadStatus, + EditMovieParams, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, + RadarrReleaseDownloadBody, }; use crate::models::servarr_data::Notification; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; @@ -1199,7 +1200,7 @@ mod tests { false, &[DownloadRecord { movie_id: 1, - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, ..DownloadRecord::default() }], 1 @@ -1215,7 +1216,7 @@ mod tests { false, &[DownloadRecord { movie_id: 1, - status: "completed".to_owned(), + status: DownloadStatus::Completed, ..DownloadRecord::default() }], 1 diff --git a/src/network/radarr_network/radarr_network_test_utils.rs b/src/network/radarr_network/radarr_network_test_utils.rs index 81f11f23..80a719db 100644 --- a/src/network/radarr_network/radarr_network_test_utils.rs +++ b/src/network/radarr_network/radarr_network_test_utils.rs @@ -2,9 +2,10 @@ pub mod test_utils { use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistItemMovie, Collection, CollectionMovie, Credit, - CreditType, DownloadRecord, DownloadsResponse, IndexerSettings, MediaInfo, MinimumAvailability, - Movie, MovieCollection, MovieFile, MovieHistoryItem, RadarrHistoryData, RadarrHistoryEventType, - RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating, RatingsList, + CreditType, DownloadRecord, DownloadStatus, DownloadsResponse, IndexerSettings, MediaInfo, + MinimumAvailability, Movie, MovieCollection, MovieFile, MovieHistoryItem, RadarrHistoryData, + RadarrHistoryEventType, RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating, + RatingsList, }; use crate::models::servarr_models::{ Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, @@ -339,7 +340,7 @@ pub mod test_utils { pub fn download_record() -> DownloadRecord { DownloadRecord { title: "Test Download Title".to_owned(), - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, id: 1, movie_id: 1, size: 3543348019, diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 79561e95..a841f003 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -1,7 +1,7 @@ use crate::app::App; use crate::logos::RADARR_LOGO; use crate::models::Route; -use crate::models::radarr_models::{DownloadRecord, Movie}; +use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie}; use crate::models::servarr_data::radarr::radarr_data::RadarrData; use crate::models::servarr_models::{DiskSpace, RootFolder}; use crate::ui::DrawUi; @@ -230,11 +230,11 @@ fn decorate_with_row_style<'a>( .iter() .find(|&download| download.movie_id == movie.id) { - if download.status == "downloading" { + if download.status == DownloadStatus::Downloading { return row.downloading(); } - if download.status == "completed" { + if download.status == DownloadStatus::Completed { return row.awaiting_import(); } } diff --git a/src/ui/radarr_ui/radarr_ui_tests.rs b/src/ui/radarr_ui/radarr_ui_tests.rs index 4c0838f3..680269cc 100644 --- a/src/ui/radarr_ui/radarr_ui_tests.rs +++ b/src/ui/radarr_ui/radarr_ui_tests.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::models::radarr_models::{DownloadRecord, Movie}; + use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie}; use pretty_assertions::assert_eq; use ratatui::widgets::{Cell, Row}; use rstest::rstest; @@ -50,8 +50,20 @@ mod tests { } #[rstest] - #[case(false, Some("downloading"), false, "", RowStyle::Downloading)] - #[case(false, Some("completed"), false, "", RowStyle::AwaitingImport)] + #[case( + false, + Some(DownloadStatus::Downloading), + false, + "", + RowStyle::Downloading + )] + #[case( + false, + Some(DownloadStatus::Completed), + false, + "", + RowStyle::AwaitingImport + )] #[case(false, None, false, "", RowStyle::UnmonitoredMissing)] #[case(false, None, true, "", RowStyle::Unreleased)] #[case(false, None, true, "released", RowStyle::Missing)] @@ -59,7 +71,7 @@ mod tests { #[case(true, None, true, "", RowStyle::Downloaded)] fn test_decorate_with_row_style( #[case] has_file: bool, - #[case] download_status: Option<&str>, + #[case] download_status: Option, #[case] is_monitored: bool, #[case] movie_status: String, #[case] expected_style: RowStyle, @@ -67,7 +79,7 @@ mod tests { let downloads_vec = if let Some(download_status) = download_status { vec![DownloadRecord { movie_id: 1, - status: download_status.to_owned(), + status: download_status, ..DownloadRecord::default() }] } else { From 1d936c5cb5d7b976cf39e92845e2219c838c9059 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 15:49:12 -0600 Subject: [PATCH 148/170] fix(radarr): serialize the credit type under the same key it is read from Radarr sends and accepts the credit type as "type" in both directions, but the field was only renamed for deserialization, so serializing a Credit emitted "creditType" and a to_value round trip could not be read back. Credit is only deserialized in production, so this affects tests and any future serialization of the record, not the live API traffic. --- src/models/radarr_models.rs | 5 +---- src/models/radarr_models_tests.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 9e1a5984..a5792855 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -127,10 +127,7 @@ pub struct Credit { pub character: Option, pub department: Option, pub job: Option, - #[serde( - rename(deserialize = "type"), - deserialize_with = "super::from_json_or_default" - )] + #[serde(rename = "type", deserialize_with = "super::from_json_or_default")] pub credit_type: CreditType, } diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 430ce115..7b9e0d6a 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -260,6 +260,18 @@ mod tests { ); } + #[test] + fn test_credit_serializes_credit_type_under_the_type_key() { + let credit_json = serde_json::to_value(crew_credit()).unwrap(); + + assert_eq!(credit_json["type"], json!("crew")); + assert_none!(credit_json.get("creditType")); + assert_eq!( + serde_json::from_value::(credit_json).unwrap(), + crew_credit() + ); + } + #[test] fn test_download_record_default_indexer_value() { let json = r#"{ From c42a46c7ca65fc2d66f16318ed61fd67dab0962d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 17:38:37 -0600 Subject: [PATCH 149/170] fix: page up and down stop at the table edges instead of wrapping A page is 20 rows and the index wrapped modulo one less than the row count, so the last row was unreachable by paging, 21-row and 5-row tables did nothing on page down from the first row, and 4-row tables cycled 0, 2, 1 and never reached row 3. Page down now clamps to the last row and page up clamps to the first. With no division left, a single-row table is safe, so the early return only guards the empty table again, matching scroll_to_top and scroll_to_bottom. --- src/handlers/handler_proptest.rs | 9 ++ src/handlers/table_handler_tests.rs | 5 +- src/models/stateful_table.rs | 11 +- src/models/stateful_table_tests.rs | 188 ++++++++++++++++++++++------ 4 files changed, 166 insertions(+), 47 deletions(-) diff --git a/src/handlers/handler_proptest.rs b/src/handlers/handler_proptest.rs index 5a216c8b..1bf29f49 100644 --- a/src/handlers/handler_proptest.rs +++ b/src/handlers/handler_proptest.rs @@ -171,10 +171,19 @@ mod property_tests { table.set_items(movies); for i in 0..page_ops { + let previous_index = table.state.selected().unwrap(); + if i % 2 == 0 { table.page_down(); + + prop_assert_eq!( + table.state.selected(), + Some((previous_index + 20).min(list_size - 1)) + ); } else { table.page_up(); + + prop_assert_eq!(table.state.selected(), Some(previous_index.saturating_sub(20))); } let current = table.current_selection(); diff --git a/src/handlers/table_handler_tests.rs b/src/handlers/table_handler_tests.rs index 45dc7b45..b234fdb1 100644 --- a/src/handlers/table_handler_tests.rs +++ b/src/handlers/table_handler_tests.rs @@ -606,17 +606,18 @@ mod tests { .take(100) .collect(); app.data.radarr_data.movies.set_items(movies_vec); + app.data.radarr_data.movies.state.select(Some(50)); TableHandlerUnit::new(key, &mut app, ActiveRadarrBlock::Movies, None).handle(); if key == Key::PgUp { assert_str_eq!( app.data.radarr_data.movies.current_selection().title.text, - "Test 79" + "Test 30" ); } else { assert_str_eq!( app.data.radarr_data.movies.current_selection().title.text, - "Test 20" + "Test 70" ); } } diff --git a/src/models/stateful_table.rs b/src/models/stateful_table.rs index 6ad579c5..6ac4a0d9 100644 --- a/src/models/stateful_table.rs +++ b/src/models/stateful_table.rs @@ -100,29 +100,26 @@ where { fn page_down(&mut self) { let items_len = self.active_items().len(); - if items_len <= 1 { + if items_len == 0 { return; } let state = self.active_state_mut(); match state.selected() { - Some(i) => state.select(Some(i.saturating_add(20) % (items_len - 1))), + Some(i) => state.select(Some(i.saturating_add(20).min(items_len - 1))), None => state.select_first(), } } fn page_up(&mut self) { let items_len = self.active_items().len(); - if items_len <= 1 { + if items_len == 0 { return; } let state = self.active_state_mut(); match state.selected() { - Some(i) => { - let len = items_len - 1; - state.select(Some((i + len - (20 % len)) % len)); - } + Some(i) => state.select(Some(i.saturating_sub(20))), None => state.select_last(), } } diff --git a/src/models/stateful_table_tests.rs b/src/models/stateful_table_tests.rs index c1851e69..b1ec752a 100644 --- a/src/models/stateful_table_tests.rs +++ b/src/models/stateful_table_tests.rs @@ -318,6 +318,69 @@ mod tests { 20 ); + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 40 + ); + + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 60 + ); + + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 80 + ); + + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 99 + ); + + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 99 + ); + + stateful_table.page_up(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 79 + ); + + stateful_table.page_up(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 59 + ); + + stateful_table.page_up(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 39 + ); + + stateful_table.page_up(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 19 + ); + stateful_table.page_up(); assert_some_eq_x!( @@ -327,33 +390,10 @@ mod tests { stateful_table.page_up(); - assert_some_eq_x!( - stateful_table.filtered_state.as_ref().unwrap().selected(), - stateful_table.filtered_items.as_ref().unwrap().len() - 21 - ); - - stateful_table.page_down(); - assert_some_eq_x!( stateful_table.filtered_state.as_ref().unwrap().selected(), 0 ); - - stateful_table.scroll_down(); - stateful_table.page_up(); - - assert_some_eq_x!( - stateful_table.filtered_state.as_ref().unwrap().selected(), - stateful_table.filtered_items.as_ref().unwrap().len() - 20 - ); - - stateful_table.scroll_down(); - stateful_table.page_down(); - - assert_some_eq_x!( - stateful_table.filtered_state.as_ref().unwrap().selected(), - 2 - ); } #[test] @@ -376,33 +416,105 @@ mod tests { assert_some_eq_x!(stateful_table.state.selected(), 20); + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 40); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 60); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 80); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 99); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 99); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 79); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 59); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 39); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 19); + stateful_table.page_up(); assert_some_eq_x!(stateful_table.state.selected(), 0); stateful_table.page_up(); - assert_some_eq_x!( - stateful_table.state.selected(), - stateful_table.items.len() - 21 - ); + assert_some_eq_x!(stateful_table.state.selected(), 0); + } - stateful_table.page_down(); + #[test] + fn test_stateful_table_pagination_on_twenty_one_item_table_stops_at_last_item() { + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items((0..21).collect()); assert_some_eq_x!(stateful_table.state.selected(), 0); - stateful_table.scroll_down(); - stateful_table.page_up(); - - assert_some_eq_x!( - stateful_table.state.selected(), - stateful_table.items.len() - 20 - ); - - stateful_table.scroll_down(); stateful_table.page_down(); - assert_some_eq_x!(stateful_table.state.selected(), 2); + assert_some_eq_x!(stateful_table.state.selected(), 20); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 20); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + } + + #[test] + fn test_stateful_table_pagination_on_five_item_table_stops_at_last_item() { + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items((0..5).collect()); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 4); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 4); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + } + + #[test] + fn test_stateful_table_pagination_on_four_item_table_reaches_last_item() { + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items((0..4).collect()); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 3); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); } #[test] From 9822597b564a3848e6b8a0c3748fb08a18ea907d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 18:06:37 -0600 Subject: [PATCH 150/170] fix(radarr): model the movie status as an enum Movie.status and AddMovieSearchResult.status were bare Strings compared against the literal "released" in the row-style decorator and in the movie details style function. They now carry a MovieStatus enum with the five values Radarr actually sends (tba, announced, inCinemas, released, deleted), shaped like MinimumAvailability and DownloadStatus, and guarded with from_json_or_default so an unrecognised status degrades to Tba instead of failing the whole fetch. Radarr never sends "Downloaded" as a movie status; its web UI derives that label from released plus hasFile. Every fixture that carried "Downloaded" now holds released, the value it was standing in for, so none of them degrade to the default through the guard. The status is never rendered as text in the Radarr tree, so no snapshot changes; the CLI JSON output is unchanged since the enum serializes to the same camelCase strings. The style_from_download_status tests now exercise the unreleased arm, which previously had no case, in place of a duplicated unmonitored-missing row. --- .../radarr_handler_test_utils.rs | 6 +- src/models/radarr_models.rs | 32 ++++++++- src/models/radarr_models_tests.rs | 65 ++++++++++++++++++- .../radarr_blocklist_network_tests.rs | 8 +-- .../library/radarr_library_network_tests.rs | 2 +- .../radarr_network_test_utils.rs | 12 ++-- src/ui/radarr_ui/library/movie_details_ui.rs | 20 +++--- .../library/movie_details_ui_tests.rs | 26 +++++--- src/ui/radarr_ui/mod.rs | 4 +- src/ui/radarr_ui/radarr_ui_tests.rs | 24 ++++--- 10 files changed, 149 insertions(+), 50 deletions(-) diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index 9eabb627..835145c9 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -5,7 +5,7 @@ pub(in crate::handlers::radarr_handlers) mod utils { use crate::models::radarr_models::{ AddMovieBody, AddMovieOptions, AddMovieSearchResult, Collection, CollectionMovie, DownloadRecord, DownloadStatus, IndexerSettings, MediaInfo, MinimumAvailability, Movie, - MovieCollection, MovieFile, MovieMonitor, RadarrRelease, Rating, RatingsList, + MovieCollection, MovieFile, MovieMonitor, MovieStatus, RadarrRelease, Rating, RatingsList, }; use crate::models::servarr_models::{ Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, @@ -329,7 +329,7 @@ pub(in crate::handlers::radarr_handlers) mod utils { title: "Test".to_owned().into(), original_language: language(), size_on_disk: 3543348019, - status: "Downloaded".to_owned(), + status: MovieStatus::Released, overview: "Blah blah blah".to_owned(), path: "/nfs/movies".to_owned(), studio: Some("21st Century Alex".to_owned()), @@ -395,7 +395,7 @@ pub(in crate::handlers::radarr_handlers) mod utils { tmdb_id: 4321, title: HorizontallyScrollableText::from("Test Search Result"), original_language: language(), - status: "released".to_owned(), + status: MovieStatus::Released, overview: "New movie blah blah blah".to_owned(), genres: genres(), year: 2023, diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index a5792855..02a5b831 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -42,7 +42,8 @@ pub struct AddMovieSearchResult { pub tmdb_id: i64, pub title: HorizontallyScrollableText, pub original_language: Language, - pub status: String, + #[serde(deserialize_with = "super::from_json_or_default")] + pub status: MovieStatus, pub overview: String, pub genres: Vec, #[serde(deserialize_with = "super::from_i64")] @@ -333,7 +334,8 @@ pub struct Movie { pub original_language: Language, #[serde(deserialize_with = "super::from_i64")] pub size_on_disk: i64, - pub status: String, + #[serde(deserialize_with = "super::from_json_or_default")] + pub status: MovieStatus, pub overview: String, pub path: String, pub studio: Option, @@ -357,6 +359,32 @@ pub struct Movie { pub collection: Option, } +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum MovieStatus { + #[default] + #[display_style(name = "TBA")] + Tba, + Announced, + #[display_style(name = "In Cinemas")] + InCinemas, + Released, + Deleted, +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct MovieCollection { diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 7b9e0d6a..93e6dbf5 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -12,7 +12,7 @@ mod tests { radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, CreditType, DiskSpace, DownloadRecord, DownloadStatus, DownloadsResponse, Indexer, IndexerSettings, - IndexerTestResult, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, + IndexerTestResult, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, MovieStatus, QualityProfile, RadarrRelease, RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update, }, servarr_models::{ @@ -20,7 +20,7 @@ mod tests { }, }; use crate::network::radarr_network::radarr_network_test_utils::test_utils::{ - MOVIE_JSON, collection, crew_credit, download_record, task, + MOVIE_JSON, add_movie_search_result, collection, crew_credit, download_record, task, }; #[test] @@ -50,6 +50,24 @@ mod tests { assert_str_eq!(MinimumAvailability::Released.to_display_str(), "Released"); } + #[test] + fn test_movie_status_display() { + assert_str_eq!(MovieStatus::Tba.to_string(), "tba"); + assert_str_eq!(MovieStatus::Announced.to_string(), "announced"); + assert_str_eq!(MovieStatus::InCinemas.to_string(), "inCinemas"); + assert_str_eq!(MovieStatus::Released.to_string(), "released"); + assert_str_eq!(MovieStatus::Deleted.to_string(), "deleted"); + } + + #[test] + fn test_movie_status_to_display_str() { + assert_str_eq!(MovieStatus::Tba.to_display_str(), "TBA"); + assert_str_eq!(MovieStatus::Announced.to_display_str(), "Announced"); + assert_str_eq!(MovieStatus::InCinemas.to_display_str(), "In Cinemas"); + assert_str_eq!(MovieStatus::Released.to_display_str(), "Released"); + assert_str_eq!(MovieStatus::Deleted.to_display_str(), "Deleted"); + } + #[test] fn test_monitor_display() { assert_str_eq!(MovieMonitor::MovieOnly.to_string(), "movieOnly"); @@ -681,6 +699,49 @@ mod tests { ); } + #[test] + fn test_movie_status_deserialization_falls_back_to_default() { + let expected = Movie { + status: MovieStatus::default(), + ..serde_json::from_str(MOVIE_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(MOVIE_JSON, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(MOVIE_JSON, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_add_movie_search_result_status_deserialization_falls_back_to_default() { + let add_movie_search_result_json = serde_json::to_string(&add_movie_search_result()).unwrap(); + let expected = AddMovieSearchResult { + status: MovieStatus::default(), + ..add_movie_search_result() + }; + + assert_eq!( + deserialize_with_field::( + &add_movie_search_result_json, + "status", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + &add_movie_search_result_json, + "status", + json!("notAThing") + ), + expected + ); + } + #[test] fn test_collection_minimum_availability_deserialization_falls_back_to_default() { let collection_json = serde_json::to_string(&collection()).unwrap(); diff --git a/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs b/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs index d74b5d05..d514b254 100644 --- a/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs +++ b/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs @@ -91,7 +91,7 @@ mod tests { "tmdbId": 1234, "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", @@ -127,7 +127,7 @@ mod tests { "tmdbId": 1234, "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", @@ -230,7 +230,7 @@ mod tests { "tmdbId": 1234, "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", @@ -266,7 +266,7 @@ mod tests { "tmdbId": 1234, "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", diff --git a/src/network/radarr_network/library/radarr_library_network_tests.rs b/src/network/radarr_network/library/radarr_library_network_tests.rs index ca4f44d4..78ba924e 100644 --- a/src/network/radarr_network/library/radarr_library_network_tests.rs +++ b/src/network/radarr_network/library/radarr_library_network_tests.rs @@ -701,7 +701,7 @@ mod tests { "name": "English" }, "sizeOnDisk": 0, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", diff --git a/src/network/radarr_network/radarr_network_test_utils.rs b/src/network/radarr_network/radarr_network_test_utils.rs index 80a719db..c64feb06 100644 --- a/src/network/radarr_network/radarr_network_test_utils.rs +++ b/src/network/radarr_network/radarr_network_test_utils.rs @@ -3,9 +3,9 @@ pub mod test_utils { use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistItemMovie, Collection, CollectionMovie, Credit, CreditType, DownloadRecord, DownloadStatus, DownloadsResponse, IndexerSettings, MediaInfo, - MinimumAvailability, Movie, MovieCollection, MovieFile, MovieHistoryItem, RadarrHistoryData, - RadarrHistoryEventType, RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating, - RatingsList, + MinimumAvailability, Movie, MovieCollection, MovieFile, MovieHistoryItem, MovieStatus, + RadarrHistoryData, RadarrHistoryEventType, RadarrHistoryItem, RadarrRelease, RadarrTask, + RadarrTaskName, Rating, RatingsList, }; use crate::models::servarr_models::{ Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, @@ -25,7 +25,7 @@ pub mod test_utils { "name": "English" }, "sizeOnDisk": 3543348019, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", @@ -234,7 +234,7 @@ pub mod test_utils { title: "Test".to_owned().into(), original_language: language(), size_on_disk: 3543348019, - status: "Downloaded".to_owned(), + status: MovieStatus::Released, overview: "Blah blah blah".to_owned(), path: "/nfs/movies".to_owned(), studio: Some("21st Century Alex".to_owned()), @@ -300,7 +300,7 @@ pub mod test_utils { tmdb_id: 1234, title: HorizontallyScrollableText::from("Test"), original_language: language(), - status: "released".to_owned(), + status: MovieStatus::Released, overview: "New movie blah blah blah".to_owned(), genres: genres(), year: 2023, diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index 60bac887..fa2f5903 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -13,7 +13,7 @@ use serde_json::Number; use crate::app::App; use crate::models::Route; -use crate::models::radarr_models::{Credit, MovieHistoryItem, RadarrRelease}; +use crate::models::radarr_models::{Credit, MovieHistoryItem, MovieStatus, RadarrRelease}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; use crate::ui::styles::ManagarrStyle; @@ -179,13 +179,7 @@ fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { match app.data.radarr_data.movie_details_modal.as_ref() { Some(movie_details_modal) if !app.is_loading => { let is_monitored = app.data.radarr_data.movies.current_selection().monitored; - let status = app - .data - .radarr_data - .movies - .current_selection() - .status - .clone(); + let status = app.data.radarr_data.movies.current_selection().status; let movie_details = &movie_details_modal.movie_details; let download_status = movie_details .items @@ -201,7 +195,7 @@ fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { .map(|line| { let split = line.split(':').collect::>(); let title = format!("{}:", split[0]); - let style = style_from_download_status(download_status, is_monitored, status.clone()); + let style = style_from_download_status(download_status, is_monitored, status); Line::from(vec![ Span::styled(title, style.bold()), @@ -531,13 +525,17 @@ fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { } } -fn style_from_download_status(download_status: &str, is_monitored: bool, status: String) -> Style { +fn style_from_download_status( + download_status: &str, + is_monitored: bool, + status: MovieStatus, +) -> Style { match download_status { "Downloaded" => downloaded_style(), "Awaiting Import" => awaiting_import_style(), "Downloading" => downloading_style(), _ if !is_monitored && download_status == "Missing" => unmonitored_missing_style(), - _ if status != "released" && download_status == "Missing" => unreleased_style(), + _ if status != MovieStatus::Released && download_status == "Missing" => unreleased_style(), "Missing" => missing_style(), _ => downloaded_style(), } 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 551297dc..cc5bf18e 100644 --- a/src/ui/radarr_ui/library/movie_details_ui_tests.rs +++ b/src/ui/radarr_ui/library/movie_details_ui_tests.rs @@ -6,6 +6,7 @@ mod tests { use strum::IntoEnumIterator; use crate::app::App; + use crate::models::radarr_models::MovieStatus; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; use crate::ui::DrawUi; use crate::ui::radarr_ui::library::movie_details_ui::{ @@ -13,7 +14,7 @@ mod tests { }; use crate::ui::styles::{ awaiting_import_style, downloaded_style, downloading_style, missing_style, - unmonitored_missing_style, + unmonitored_missing_style, unreleased_style, }; use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; @@ -29,21 +30,26 @@ mod tests { } #[rstest] - #[case("Downloading", true, "", downloading_style())] - #[case("Downloaded", true, "", downloaded_style())] - #[case("Awaiting Import", true, "", awaiting_import_style())] - #[case("Missing", false, "", unmonitored_missing_style())] - #[case("Missing", false, "", unmonitored_missing_style())] - #[case("Missing", true, "released", missing_style())] - #[case("", true, "", downloaded_style())] + #[case("Downloading", true, MovieStatus::Released, downloading_style())] + #[case("Downloaded", true, MovieStatus::Released, downloaded_style())] + #[case( + "Awaiting Import", + true, + MovieStatus::Released, + awaiting_import_style() + )] + #[case("Missing", false, MovieStatus::InCinemas, unmonitored_missing_style())] + #[case("Missing", true, MovieStatus::InCinemas, unreleased_style())] + #[case("Missing", true, MovieStatus::Released, missing_style())] + #[case("", true, MovieStatus::Released, downloaded_style())] fn test_style_from_download_status( #[case] download_status: &str, #[case] is_monitored: bool, - #[case] movie_status: &str, + #[case] movie_status: MovieStatus, #[case] expected_style: Style, ) { assert_eq!( - style_from_download_status(download_status, is_monitored, movie_status.to_owned()), + style_from_download_status(download_status, is_monitored, movie_status), expected_style ); } diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index a841f003..a50fc898 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -1,7 +1,7 @@ use crate::app::App; use crate::logos::RADARR_LOGO; use crate::models::Route; -use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie}; +use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie, MovieStatus}; use crate::models::servarr_data::radarr::radarr_data::RadarrData; use crate::models::servarr_models::{DiskSpace, RootFolder}; use crate::ui::DrawUi; @@ -243,7 +243,7 @@ fn decorate_with_row_style<'a>( return row.unmonitored_missing(); } - if movie.status != "released" { + if movie.status != MovieStatus::Released { return row.unreleased(); } diff --git a/src/ui/radarr_ui/radarr_ui_tests.rs b/src/ui/radarr_ui/radarr_ui_tests.rs index 680269cc..17b2dc75 100644 --- a/src/ui/radarr_ui/radarr_ui_tests.rs +++ b/src/ui/radarr_ui/radarr_ui_tests.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie}; + use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie, MovieStatus}; use pretty_assertions::assert_eq; use ratatui::widgets::{Cell, Row}; use rstest::rstest; @@ -54,26 +54,32 @@ mod tests { false, Some(DownloadStatus::Downloading), false, - "", + MovieStatus::Released, RowStyle::Downloading )] #[case( false, Some(DownloadStatus::Completed), false, - "", + MovieStatus::Released, RowStyle::AwaitingImport )] - #[case(false, None, false, "", RowStyle::UnmonitoredMissing)] - #[case(false, None, true, "", RowStyle::Unreleased)] - #[case(false, None, true, "released", RowStyle::Missing)] - #[case(true, None, false, "", RowStyle::Unmonitored)] - #[case(true, None, true, "", RowStyle::Downloaded)] + #[case( + false, + None, + false, + MovieStatus::Released, + RowStyle::UnmonitoredMissing + )] + #[case(false, None, true, MovieStatus::InCinemas, RowStyle::Unreleased)] + #[case(false, None, true, MovieStatus::Released, RowStyle::Missing)] + #[case(true, None, false, MovieStatus::Released, RowStyle::Unmonitored)] + #[case(true, None, true, MovieStatus::Released, RowStyle::Downloaded)] fn test_decorate_with_row_style( #[case] has_file: bool, #[case] download_status: Option, #[case] is_monitored: bool, - #[case] movie_status: String, + #[case] movie_status: MovieStatus, #[case] expected_style: RowStyle, ) { let downloads_vec = if let Some(download_status) = download_status { From 4f78a5940fce00b9a8e418ebfa675ee00ca525d2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 18:16:17 -0600 Subject: [PATCH 151/170] test(lidarr): give the track and history fixtures distinct ids The shared track held id, artist id, track file id, and album id all equal to 1, and the shared history item held id, album id, artist id, and track id all equal to 1, so reading the wrong one of those fields could not fail any test that uses them. Move the track to id 5, artist id 3, and track file id 7 (with the shared track file moved to id 7 so the album view still finds its quality and audio info), and move the history item to id 13, artist id 3, and track id 5. Both album ids stay at 1 so the fully populated album view still shows its track as downloading. Update the seven assertions that named the old ids. --- src/app/lidarr/lidarr_tests.rs | 6 +++--- src/cli/lidarr/list_command_handler_tests.rs | 2 +- .../library/album_details_handler_tests.rs | 6 +++--- .../lidarr_network/lidarr_network_test_utils.rs | 14 +++++++------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs index 4b530139..0ea48a8b 100644 --- a/src/app/lidarr/lidarr_tests.rs +++ b/src/app/lidarr/lidarr_tests.rs @@ -498,7 +498,7 @@ mod tests { assert!(app.is_loading); assert_eq!( rx.recv().await.unwrap(), - LidarrEvent::GetTrackDetails(1).into() + LidarrEvent::GetTrackDetails(5).into() ); assert!(!app.data.lidarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); @@ -518,7 +518,7 @@ mod tests { assert!(app.is_loading); assert_eq!( rx.recv().await.unwrap(), - LidarrEvent::GetTrackHistory(1, 1, 1).into() + LidarrEvent::GetTrackHistory(1, 1, 5).into() ); assert!(!app.data.lidarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); @@ -759,7 +759,7 @@ mod tests { album_details_modal.tracks.set_items(vec![track()]); app.data.lidarr_data.album_details_modal = Some(album_details_modal); - assert_eq!(app.extract_track_id().await, 1); + assert_eq!(app.extract_track_id().await, 5); } #[tokio::test] diff --git a/src/cli/lidarr/list_command_handler_tests.rs b/src/cli/lidarr/list_command_handler_tests.rs index 07456a1a..e047c412 100644 --- a/src/cli/lidarr/list_command_handler_tests.rs +++ b/src/cli/lidarr/list_command_handler_tests.rs @@ -631,7 +631,7 @@ mod tests { async fn test_handle_list_track_history_command() { let expected_artist_id = 1; let expected_album_id = 1; - let expected_track_id = 1; + let expected_track_id = 5; let mut mock_network = MockNetworkTrait::new(); mock_network .expect_handle_network_event() diff --git a/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs index eb3ad788..edfde49a 100644 --- a/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs @@ -255,7 +255,7 @@ mod tests { )] #[case( ActiveLidarrBlock::DeleteTrackFilePrompt, - LidarrEvent::DeleteTrackFile(1) + LidarrEvent::DeleteTrackFile(7) )] fn test_album_details_prompt_confirm_submit( #[case] prompt_block: ActiveLidarrBlock, @@ -606,7 +606,7 @@ mod tests { )] #[case( ActiveLidarrBlock::DeleteTrackFilePrompt, - LidarrEvent::DeleteTrackFile(1) + LidarrEvent::DeleteTrackFile(7) )] fn test_album_details_prompt_confirm_confirm_key( #[case] prompt_block: ActiveLidarrBlock, @@ -752,7 +752,7 @@ mod tests { ) .extract_track_file_id(); - assert_eq!(track_file_id, 1); + assert_eq!(track_file_id, 7); } #[test] diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index a841f9b9..e16faee8 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -282,11 +282,11 @@ pub mod test_utils { pub fn lidarr_history_item() -> LidarrHistoryItem { LidarrHistoryItem { - id: 1, + id: 13, source_title: "Test source title".into(), album_id: 1, - artist_id: 1, - track_id: 1, + artist_id: 3, + track_id: 5, quality: quality_wrapper(), date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), event_type: LidarrHistoryEventType::Grabbed, @@ -459,7 +459,7 @@ pub mod test_utils { pub fn track_file() -> TrackFile { TrackFile { - id: 1, + id: 7, path: "/music/P!nk/TRUSTFALL/01 - When I Get There.flac".to_string(), size: 39216378, quality: quality_wrapper(), @@ -471,10 +471,10 @@ pub mod test_utils { pub fn track() -> Track { Track { - id: 1, - artist_id: 1, + id: 5, + artist_id: 3, foreign_track_id: "test-foreign-track-id".to_string(), - track_file_id: 1, + track_file_id: 7, album_id: 1, explicit: false, track_number: "1".to_string(), From 040935178a5dc512a704c92fcb12602945afc2bb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 18:22:31 -0600 Subject: [PATCH 152/170] test(sonarr): give the download record and episode fixtures distinct ids The shared download record held id and episode id both equal to 1, and the shared episode held id and episode file id both equal to 1, so reading the wrong one of those fields could not fail any test that uses them. Move the download record to id 9 and the episode to id 11, keeping the download record's episode id equal to the episode's id so the downloading episode still matches, and move the episode file to id 7 together with the episode's episode file id so the season view still finds its quality and size. Series id, season number, and episode number stay at 1. The handler tree keeps its own copy of these fixtures; update it identically, along with the JSON twins in the network tests and the seven handler assertions that named the old ids. The two get episodes tests that asserted the sorted table now spell out the sorted order, since the fixture episode no longer sorts first by id. --- .../downloads/downloads_handler_tests.rs | 6 +++--- .../library/episode_details_handler_tests.rs | 6 +++--- .../library/season_details_handler_tests.rs | 4 ++-- .../sonarr_handlers/sonarr_handler_test_utils.rs | 10 +++++----- .../downloads/sonarr_downloads_network_tests.rs | 4 ++-- .../episodes/sonarr_episodes_network_tests.rs | 9 +++++---- .../sonarr_network/sonarr_network_test_utils.rs | 16 ++++++++-------- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs index 876fa64e..cedd12cf 100644 --- a/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs @@ -140,7 +140,7 @@ mod tests { #[case( ActiveSonarrBlock::Downloads, ActiveSonarrBlock::DeleteDownloadPrompt, - SonarrEvent::DeleteDownload(1) + SonarrEvent::DeleteDownload(9) )] #[case( ActiveSonarrBlock::Downloads, @@ -340,7 +340,7 @@ mod tests { #[case( ActiveSonarrBlock::Downloads, ActiveSonarrBlock::DeleteDownloadPrompt, - SonarrEvent::DeleteDownload(1) + SonarrEvent::DeleteDownload(9) )] #[case( ActiveSonarrBlock::Downloads, @@ -431,7 +431,7 @@ mod tests { ) .extract_download_id(); - assert_eq!(download_id, 1); + assert_eq!(download_id, 9); } #[test] diff --git a/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs index cd5c5920..1b57bfb8 100644 --- a/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs @@ -207,7 +207,7 @@ mod tests { #[rstest] #[case( ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, - SonarrEvent::TriggerAutomaticEpisodeSearch(1) + SonarrEvent::TriggerAutomaticEpisodeSearch(11) )] fn test_episode_details_prompt_confirm_submit( #[case] prompt_block: ActiveSonarrBlock, @@ -646,7 +646,7 @@ mod tests { assert_navigation_popped!(app, active_sonarr_block.into()); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::TriggerAutomaticEpisodeSearch(1) + &SonarrEvent::TriggerAutomaticEpisodeSearch(11) ); } @@ -787,7 +787,7 @@ mod tests { ) .extract_episode_id(); - assert_eq!(episode_id, 1); + assert_eq!(episode_id, 11); } #[test] 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 864d817e..471644ce 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs @@ -673,7 +673,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::ToggleEpisodeMonitoring(1) + &SonarrEvent::ToggleEpisodeMonitoring(11) ); } @@ -1129,7 +1129,7 @@ mod tests { ) .extract_episode_id(); - assert_eq!(episode_id, 1); + assert_eq!(episode_id, 11); } #[test] diff --git a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs index 363079d8..88f1874a 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs @@ -191,8 +191,8 @@ pub(in crate::handlers::sonarr_handlers) mod utils { DownloadRecord { title: "Test Download Title".to_owned(), status: DownloadStatus::Downloading, - id: 1, - episode_id: Some(Number::from(1i64)), + id: 9, + episode_id: Some(Number::from(11i64)), size: 3543348019f64, sizeleft: 1771674009f64, output_path: Some(HorizontallyScrollableText::from( @@ -205,10 +205,10 @@ pub(in crate::handlers::sonarr_handlers) mod utils { pub fn episode() -> Episode { Episode { - id: 1, + id: 11, series_id: 1, tvdb_id: 1234, - episode_file_id: 1, + episode_file_id: 7, season_number: 1, episode_number: 1, title: "Something cool".to_owned(), @@ -224,7 +224,7 @@ pub(in crate::handlers::sonarr_handlers) mod utils { pub fn episode_file() -> EpisodeFile { EpisodeFile { - id: 1, + id: 7, relative_path: "/season 1/episode 1.mkv".to_owned(), path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), size: 3543348019, diff --git a/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs b/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs index 3a74f396..1e0e2c0e 100644 --- a/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs +++ b/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs @@ -41,8 +41,8 @@ mod tests { "records": [{ "title": "Test Download Title", "status": "downloading", - "id": 1, - "episodeId": 1, + "id": 9, + "episodeId": 11, "size": 3543348019f64, "sizeleft": 1771674009f64, "outputPath": "/nfs/tv/Test show/season 1/", diff --git a/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs b/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs index 62b2530d..d20686ab 100644 --- a/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs +++ b/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs @@ -69,7 +69,7 @@ mod tests { ..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 mut expected_sorted_episodes = vec![episode_3.clone(), episode_1.clone()]; let (mock, app, _server) = MockServarrApi::get() .query("seriesId=1") .returns(json!([episode_1, episode_2, episode_3])) @@ -175,6 +175,7 @@ mod tests { ..episode() }; let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()]; + let expected_sorted_episodes = vec![episode_2.clone(), episode_3.clone(), episode_1.clone()]; let (mock, app, _server) = MockServarrApi::get() .query("seriesId=1") .returns(json!([episode_1, episode_2, episode_3])) @@ -216,7 +217,7 @@ mod tests { .unwrap() .episodes .items, - expected_episodes + expected_sorted_episodes ); assert!( app @@ -294,10 +295,10 @@ mod tests { "monitored": true }, { - "id": 1, + "id": 11, "seriesId": 1, "tvdbId": 1234, - "episodeFileId": 1, + "episodeFileId": 7, "seasonNumber": 1, "episodeNumber": 1, "title": "Something cool", diff --git a/src/network/sonarr_network/sonarr_network_test_utils.rs b/src/network/sonarr_network/sonarr_network_test_utils.rs index 1838a28a..03af7a5c 100644 --- a/src/network/sonarr_network/sonarr_network_test_utils.rs +++ b/src/network/sonarr_network/sonarr_network_test_utils.rs @@ -64,14 +64,14 @@ pub mod test_utils { pub const EPISODE_JSON: &str = r#"{ "seriesId": 1, "tvdbId": 1234, - "episodeFileId": 1, + "episodeFileId": 7, "seasonNumber": 1, "episodeNumber": 1, "title": "Something cool", "airDateUtc": "2024-02-10T07:28:45Z", "overview": "Okay so this one time at band camp...", "episodeFile": { - "id": 1, + "id": 7, "relativePath": "/season 1/episode 1.mkv", "path": "/nfs/tv/series/season 1/episode 1.mkv", "size": 3543348019, @@ -96,7 +96,7 @@ pub mod test_utils { }, "hasFile": true, "monitored": true, - "id": 1 + "id": 11 }"#; pub fn add_series_search_result() -> AddSeriesSearchResult { @@ -139,8 +139,8 @@ pub mod test_utils { DownloadRecord { title: "Test Download Title".to_owned(), status: DownloadStatus::Downloading, - id: 1, - episode_id: Some(Number::from(1i64)), + id: 9, + episode_id: Some(Number::from(11i64)), size: 3543348019f64, sizeleft: 1771674009f64, output_path: Some(HorizontallyScrollableText::from( @@ -159,10 +159,10 @@ pub mod test_utils { pub fn episode() -> Episode { Episode { - id: 1, + id: 11, series_id: 1, tvdb_id: 1234, - episode_file_id: 1, + episode_file_id: 7, season_number: 1, episode_number: 1, title: "Something cool".to_owned(), @@ -178,7 +178,7 @@ pub mod test_utils { pub fn episode_file() -> EpisodeFile { EpisodeFile { - id: 1, + id: 7, relative_path: "/season 1/episode 1.mkv".to_owned(), path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), size: 3543348019, From 35ed3f359d1d77916eadcdceae210554a2878de7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 18:32:51 -0600 Subject: [PATCH 153/170] refactor: share one DownloadStatus enum across the servarr models DownloadStatus was declared four times, byte for byte identically, in radarr_models, sonarr_models, lidarr_models, and readarr_models: the same variants, the same derive list, and the same serde, strum, and display_style attributes. Every servarr's queue endpoint reports the same status vocabulary, so there is nothing servarr specific about the type. Move it into servarr_models, preserving that file's alphabetical ordering (between DiskSpace and IndexerSettings), and drop the four copies. The DownloadRecord types keep their per-servarr shapes and now reference the shared enum. The two enum tests, test_download_status_display and test_download_status_to_display_str, were likewise duplicated in the four per-servarr model test files. Keep one copy of each in servarr_models_tests alongside the other shared enum display tests and delete the rest. The per-servarr DownloadRecord fallback deserialization tests stay where they are since they exercise each servarr's own record type. Behavior is unchanged. Every remaining edit is an import path update. --- .../radarr_handler_test_utils.rs | 6 +-- .../downloads/downloads_handler_tests.rs | 3 +- .../sonarr_handler_test_utils.rs | 8 ++-- src/models/lidarr_models.rs | 34 +------------- src/models/lidarr_models_tests.rs | 46 +++---------------- src/models/radarr_models.rs | 34 +------------- src/models/radarr_models_tests.rs | 43 ++--------------- src/models/readarr_models.rs | 34 +------------- src/models/readarr_models_tests.rs | 42 ++--------------- .../servarr_data/readarr/readarr_data.rs | 6 +-- src/models/servarr_models.rs | 32 +++++++++++++ src/models/servarr_models_tests.rs | 38 ++++++++++++++- src/models/sonarr_models.rs | 34 +------------- src/models/sonarr_models_tests.rs | 42 ++--------------- .../lidarr_network_test_utils.rs | 6 +-- src/network/radarr_network/library/mod.rs | 3 +- .../library/radarr_library_network_tests.rs | 6 +-- .../radarr_network_test_utils.rs | 10 ++-- .../readarr_network_test_utils.rs | 7 ++- .../sonarr_network/library/episodes/mod.rs | 4 +- .../episodes/sonarr_episodes_network_tests.rs | 3 +- .../sonarr_network_test_utils.rs | 8 ++-- src/ui/lidarr_ui/library/album_details_ui.rs | 5 +- .../library/album_details_ui_tests.rs | 3 +- src/ui/radarr_ui/mod.rs | 4 +- src/ui/radarr_ui/radarr_ui_tests.rs | 3 +- .../downloads/downloads_ui_tests.rs | 3 +- .../sonarr_ui/library/episode_details_ui.rs | 6 +-- .../library/episode_details_ui_tests.rs | 3 +- src/ui/sonarr_ui/library/season_details_ui.rs | 5 +- .../library/season_details_ui_tests.rs | 3 +- 31 files changed, 149 insertions(+), 335 deletions(-) diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index 835145c9..d277e7f2 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -4,11 +4,11 @@ pub(in crate::handlers::radarr_handlers) mod utils { use crate::models::HorizontallyScrollableText; use crate::models::radarr_models::{ AddMovieBody, AddMovieOptions, AddMovieSearchResult, Collection, CollectionMovie, - DownloadRecord, DownloadStatus, IndexerSettings, MediaInfo, MinimumAvailability, Movie, - MovieCollection, MovieFile, MovieMonitor, MovieStatus, RadarrRelease, Rating, RatingsList, + DownloadRecord, IndexerSettings, MediaInfo, MinimumAvailability, Movie, MovieCollection, + MovieFile, MovieMonitor, MovieStatus, RadarrRelease, Rating, RatingsList, }; use crate::models::servarr_models::{ - Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, }; use chrono::DateTime; use serde_json::{Number, json}; diff --git a/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs index b8666b0e..c803be20 100644 --- a/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs @@ -11,8 +11,9 @@ mod tests { use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::readarr_handlers::downloads::DownloadsHandler; - use crate::models::readarr_models::{DownloadRecord, DownloadStatus}; + use crate::models::readarr_models::DownloadRecord; use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; + use crate::models::servarr_models::DownloadStatus; mod test_handle_delete { use pretty_assertions::assert_eq; diff --git a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs index 88f1874a..24f6e166 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs @@ -4,12 +4,12 @@ pub(in crate::handlers::sonarr_handlers) mod utils { use crate::models::HorizontallyScrollableText; use crate::models::servarr_models::IndexerSettings; use crate::models::servarr_models::{ - Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, }; use crate::models::sonarr_models::{ - AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadRecord, DownloadStatus, - Episode, EpisodeFile, MediaInfo, Rating, Season, SeasonStatistics, Series, SeriesStatistics, - SeriesStatus, SeriesType, + AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadRecord, Episode, EpisodeFile, + MediaInfo, Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, + SeriesType, }; use chrono::DateTime; use serde_json::{Number, json}; diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 3d22bab6..1b466d55 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -1,8 +1,8 @@ use super::{ HorizontallyScrollableText, Serdeable, servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerTestResult, MetadataProfile, QualityProfile, - QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag, + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerTestResult, MetadataProfile, + QualityProfile, QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag, }, }; use crate::models::servarr_models::{IndexerSettings, LogResponse, QueueEvent, Update}; @@ -181,36 +181,6 @@ pub struct DownloadRecord { impl Eq for DownloadRecord {} -#[derive( - Serialize, - Deserialize, - Default, - PartialEq, - Eq, - Clone, - Copy, - Debug, - EnumIter, - Display, - EnumDisplayStyle, -)] -#[serde(rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum DownloadStatus { - #[default] - Unknown, - Queued, - Paused, - Downloading, - Completed, - Failed, - Warning, - Delay, - #[display_style(name = "Download Client Unavailable")] - DownloadClientUnavailable, - Fallback, -} - #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 66a1f083..5abbd6a2 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -7,14 +7,14 @@ mod tests { use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, - DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, - LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, - MonitorType, NewItemMonitorType, Track, TrackFile, + DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, + LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, MonitorType, NewItemMonitorType, + Track, TrackFile, }; use crate::models::servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, - MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, - Update, + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, + LogResponse, MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, + SystemStatus, Tag, Update, }; use crate::models::{ Serdeable, @@ -659,40 +659,6 @@ mod tests { assert_str_eq!(ArtistStatus::Deleted.to_display_str(), "Deleted"); } - #[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_lidarr_history_event_type_display() { assert_str_eq!(LidarrHistoryEventType::Unknown.to_string(), "unknown"); diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 02a5b831..8be82e62 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -12,8 +12,8 @@ use strum_macros::{Display, EnumIter}; use super::Serdeable; use super::servarr_models::{ - DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update, + DiskSpace, DownloadStatus, HostConfig, Indexer, Language, LogResponse, QualityProfile, + QualityWrapper, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update, }; #[cfg(test)] @@ -168,36 +168,6 @@ pub struct DownloadRecord { pub download_client: String, } -#[derive( - Serialize, - Deserialize, - Default, - PartialEq, - Eq, - Clone, - Copy, - Debug, - EnumIter, - Display, - EnumDisplayStyle, -)] -#[serde(rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum DownloadStatus { - #[default] - Unknown, - Queued, - Paused, - Downloading, - Completed, - Failed, - Warning, - Delay, - #[display_style(name = "Download Client Unavailable")] - DownloadClientUnavailable, - Fallback, -} - #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 93e6dbf5..bd67b6e6 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -11,12 +11,13 @@ mod tests { Serdeable, radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, CreditType, - DiskSpace, DownloadRecord, DownloadStatus, DownloadsResponse, Indexer, IndexerSettings, - IndexerTestResult, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, MovieStatus, - QualityProfile, RadarrRelease, RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update, + DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, + MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, MovieStatus, QualityProfile, + RadarrRelease, RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update, }, servarr_models::{ - HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, SystemStatus, + DownloadStatus, HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, + SystemStatus, }, }; use crate::network::radarr_network::radarr_network_test_utils::test_utils::{ @@ -88,40 +89,6 @@ mod tests { assert_str_eq!(MovieMonitor::None.to_display_str(), "None"); } - #[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_radarr_history_event_type_display() { assert_str_eq!(RadarrHistoryEventType::Unknown.to_string(), "unknown"); diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index c924b19a..0f4cf4ac 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -1,8 +1,8 @@ use super::{ HorizontallyScrollableText, Serdeable, servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerTestResult, MetadataProfile, QualityProfile, - QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag, + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerTestResult, MetadataProfile, + QualityProfile, QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag, }, }; use crate::models::servarr_models::{IndexerSettings, LogResponse, QueueEvent, Update}; @@ -172,36 +172,6 @@ pub struct DownloadRecord { impl Eq for DownloadRecord {} -#[derive( - Serialize, - Deserialize, - Default, - PartialEq, - Eq, - Clone, - Copy, - Debug, - EnumIter, - Display, - EnumDisplayStyle, -)] -#[serde(rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum DownloadStatus { - #[default] - Unknown, - Queued, - Paused, - Downloading, - Completed, - Failed, - Warning, - Delay, - #[display_style(name = "Download Client Unavailable")] - DownloadClientUnavailable, - Fallback, -} - #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { diff --git a/src/models/readarr_models_tests.rs b/src/models/readarr_models_tests.rs index 867495c4..0fd62ebf 100644 --- a/src/models/readarr_models_tests.rs +++ b/src/models/readarr_models_tests.rs @@ -7,14 +7,14 @@ mod tests { use crate::models::readarr_models::{ AddAuthorSearchResult, BlocklistItem, BlocklistResponse, Book, BookFile, DownloadRecord, - DownloadStatus, DownloadsResponse, Edition, MediaInfo, MonitorType, NewItemMonitorType, + DownloadsResponse, Edition, MediaInfo, MonitorType, NewItemMonitorType, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrHistoryWrapper, ReadarrRelease, ReadarrTask, ReadarrTaskName, }; use crate::models::servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, - MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, - Update, + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, + LogResponse, MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, + SystemStatus, Tag, Update, }; use crate::models::{ Serdeable, @@ -644,40 +644,6 @@ mod tests { assert_str_eq!(AuthorStatus::Deleted.to_display_str(), "Deleted"); } - #[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_readarr_history_event_type_display() { assert_str_eq!(ReadarrHistoryEventType::Unknown.to_string(), "unknown"); diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index ba2cc4c0..df0d8379 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -31,10 +31,10 @@ use strum::EnumIter; use { super::modals::EditionDetailsModal, crate::models::readarr_models::{ - AuthorStatus, BookFile, BookStatistics, DownloadStatus, Edition, MediaInfo, MonitorType, - NewItemMonitorType, Ratings, ReadarrHistoryEventType, ReadarrTaskName, + AuthorStatus, BookFile, BookStatistics, Edition, MediaInfo, MonitorType, NewItemMonitorType, + Ratings, ReadarrHistoryEventType, ReadarrTaskName, }, - crate::models::servarr_models::{IndexerField, Quality, QualityWrapper}, + crate::models::servarr_models::{DownloadStatus, IndexerField, Quality, QualityWrapper}, crate::models::stateful_table::SortOption, crate::network::servarr_test_utils::{ diskspace, indexer_settings, indexer_test_result, queued_event, diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 1d87f8c2..ee6d562a 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -2,8 +2,10 @@ use std::fmt::{Display, Formatter, Result}; use chrono::{DateTime, Utc}; use clap::ValueEnum; +use enum_display_style_derive::EnumDisplayStyle; use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; +use strum::{Display, EnumIter}; use super::HorizontallyScrollableText; @@ -90,6 +92,36 @@ pub struct DiskSpace { pub total_space: i64, } +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum DownloadStatus { + #[default] + Unknown, + Queued, + Paused, + Downloading, + Completed, + Failed, + Warning, + Delay, + #[display_style(name = "Download Client Unavailable")] + DownloadClientUnavailable, + Fallback, +} + #[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct IndexerSettings { diff --git a/src/models/servarr_models_tests.rs b/src/models/servarr_models_tests.rs index 98fd176d..2678aa49 100644 --- a/src/models/servarr_models_tests.rs +++ b/src/models/servarr_models_tests.rs @@ -5,8 +5,8 @@ mod tests { use serde_json::{Value, json}; use crate::models::servarr_models::{ - AuthenticationMethod, AuthenticationRequired, CertificateValidation, Indexer, QualityProfile, - SecurityConfig, Update, UpdateChanges, + AuthenticationMethod, AuthenticationRequired, CertificateValidation, DownloadStatus, Indexer, + QualityProfile, SecurityConfig, Update, UpdateChanges, }; #[test] @@ -56,6 +56,40 @@ mod tests { assert_str_eq!(CertificateValidation::Disabled.to_string(), "disabled"); } + #[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_quality_profile_from_tuple_ref() { let id = 2; diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index aca2c844..89d37451 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -16,8 +16,8 @@ use strum_macros::Display; use super::{ HorizontallyScrollableText, Serdeable, servarr_models::{ - DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update, + DiskSpace, DownloadStatus, HostConfig, Indexer, Language, LogResponse, QualityProfile, + QualityWrapper, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update, }, }; @@ -129,36 +129,6 @@ pub struct DownloadRecord { impl Eq for DownloadRecord {} -#[derive( - Serialize, - Deserialize, - Default, - PartialEq, - Eq, - Clone, - Copy, - Debug, - EnumIter, - Display, - EnumDisplayStyle, -)] -#[serde(rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum DownloadStatus { - #[default] - Unknown, - Queued, - Paused, - Downloading, - Completed, - Failed, - Warning, - Delay, - #[display_style(name = "Download Client Unavailable")] - DownloadClientUnavailable, - 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 286cb714..7517a15d 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -7,13 +7,13 @@ mod tests { use crate::models::{ Serdeable, servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Language, Log, - LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Language, + Log, LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update, }, sonarr_models::{ - AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, - DownloadsResponse, Episode, EpisodeFile, Series, SeriesMonitor, SeriesStatus, SeriesType, + AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, + Episode, EpisodeFile, Series, SeriesMonitor, SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, SonarrTaskName, }, @@ -123,40 +123,6 @@ 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/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index e16faee8..d35ad579 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -3,15 +3,15 @@ pub mod test_utils { use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus, - AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse, + AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, EditArtistParams, LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, NewItemMonitorType, Ratings, Track, TrackFile, }; use crate::models::servarr_models::IndexerSettings; use crate::models::servarr_models::{ - Indexer, IndexerField, MetadataProfile, Quality, QualityProfile, QualityWrapper, RootFolder, - SystemStatus, Tag, + DownloadStatus, Indexer, IndexerField, MetadataProfile, Quality, QualityProfile, + QualityWrapper, RootFolder, SystemStatus, Tag, }; use crate::models::{HorizontallyScrollableText, ScrollableText}; use bimap::BiMap; diff --git a/src/network/radarr_network/library/mod.rs b/src/network/radarr_network/library/mod.rs index cbc1027d..7bee027e 100644 --- a/src/network/radarr_network/library/mod.rs +++ b/src/network/radarr_network/library/mod.rs @@ -1,11 +1,12 @@ use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, Credit, CreditType, DeleteMovieParams, DownloadRecord, - DownloadStatus, EditMovieParams, Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease, + EditMovieParams, Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease, RadarrReleaseDownloadBody, }; use crate::models::servarr_data::Notification; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::servarr_models::DownloadStatus; use crate::models::stateful_table::StatefulTable; use crate::models::{Route, ScrollableText}; use crate::network::radarr_network::RadarrEvent; diff --git a/src/network/radarr_network/library/radarr_library_network_tests.rs b/src/network/radarr_network/library/radarr_library_network_tests.rs index 78ba924e..97ca4491 100644 --- a/src/network/radarr_network/library/radarr_library_network_tests.rs +++ b/src/network/radarr_network/library/radarr_library_network_tests.rs @@ -1,13 +1,13 @@ #[cfg(test)] mod tests { use crate::models::radarr_models::{ - AddMovieBody, AddMovieOptions, Credit, DeleteMovieParams, DownloadRecord, DownloadStatus, - EditMovieParams, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, - RadarrReleaseDownloadBody, + AddMovieBody, AddMovieOptions, Credit, DeleteMovieParams, DownloadRecord, EditMovieParams, + MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, RadarrReleaseDownloadBody, }; use crate::models::servarr_data::Notification; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_models::DownloadStatus; use crate::models::stateful_table::SortOption; use crate::network::NetworkResource; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; diff --git a/src/network/radarr_network/radarr_network_test_utils.rs b/src/network/radarr_network/radarr_network_test_utils.rs index c64feb06..866bdbe2 100644 --- a/src/network/radarr_network/radarr_network_test_utils.rs +++ b/src/network/radarr_network/radarr_network_test_utils.rs @@ -2,13 +2,13 @@ pub mod test_utils { use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistItemMovie, Collection, CollectionMovie, Credit, - CreditType, DownloadRecord, DownloadStatus, DownloadsResponse, IndexerSettings, MediaInfo, - MinimumAvailability, Movie, MovieCollection, MovieFile, MovieHistoryItem, MovieStatus, - RadarrHistoryData, RadarrHistoryEventType, RadarrHistoryItem, RadarrRelease, RadarrTask, - RadarrTaskName, Rating, RatingsList, + CreditType, DownloadRecord, DownloadsResponse, IndexerSettings, MediaInfo, MinimumAvailability, + Movie, MovieCollection, MovieFile, MovieHistoryItem, MovieStatus, RadarrHistoryData, + RadarrHistoryEventType, RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating, + RatingsList, }; use crate::models::servarr_models::{ - Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, }; use crate::models::{HorizontallyScrollableText, ScrollableText}; use bimap::BiMap; diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 60086768..fa7bbd34 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -4,13 +4,12 @@ pub mod test_utils { use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{ AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, BlocklistItem, - Book, BookFile, DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType, - NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, - ReadarrRelease, + Book, BookFile, DownloadRecord, DownloadsResponse, Edition, MonitorType, NewItemMonitorType, + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease, }; use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_models::{ - Indexer, IndexerField, IndexerSettings, Quality, QualityWrapper, RootFolder, + DownloadStatus, Indexer, IndexerField, IndexerSettings, Quality, QualityWrapper, RootFolder, }; use chrono::DateTime; use serde_json::{Number, json}; diff --git a/src/network/sonarr_network/library/episodes/mod.rs b/src/network/sonarr_network/library/episodes/mod.rs index 0eec86db..897e5ba9 100644 --- a/src/network/sonarr_network/library/episodes/mod.rs +++ b/src/network/sonarr_network/library/episodes/mod.rs @@ -1,8 +1,8 @@ use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; -use crate::models::servarr_models::Language; +use crate::models::servarr_models::{DownloadStatus, Language}; use crate::models::sonarr_models::{ - DownloadRecord, DownloadStatus, Episode, EpisodeFile, MonitorEpisodeBody, SonarrCommandBody, + DownloadRecord, Episode, EpisodeFile, MonitorEpisodeBody, SonarrCommandBody, SonarrHistoryWrapper, SonarrRelease, }; use crate::models::{Route, ScrollableText}; diff --git a/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs b/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs index d20686ab..ebaf8e6d 100644 --- a/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs +++ b/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs @@ -2,8 +2,9 @@ mod tests { use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal}; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::servarr_models::DownloadStatus; use crate::models::sonarr_models::{ - DownloadRecord, DownloadStatus, Episode, MonitorEpisodeBody, Season, Series, SonarrHistoryItem, + DownloadRecord, Episode, MonitorEpisodeBody, Season, Series, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, SonarrSerdeable, }; use crate::models::stateful_table::SortOption; diff --git a/src/network/sonarr_network/sonarr_network_test_utils.rs b/src/network/sonarr_network/sonarr_network_test_utils.rs index 03af7a5c..ff651cc5 100644 --- a/src/network/sonarr_network/sonarr_network_test_utils.rs +++ b/src/network/sonarr_network/sonarr_network_test_utils.rs @@ -1,13 +1,13 @@ #[cfg(test)] pub mod test_utils { use crate::models::servarr_models::{ - Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, }; use crate::models::sonarr_models::{ AddSeriesSearchResult, AddSeriesSearchResultStatistics, BlocklistItem, DownloadRecord, - DownloadStatus, DownloadsResponse, Episode, EpisodeFile, MediaInfo, Rating, Season, - SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType, SonarrHistoryData, - SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrTask, SonarrTaskName, + DownloadsResponse, Episode, EpisodeFile, MediaInfo, Rating, Season, SeasonStatistics, Series, + SeriesStatistics, SeriesStatus, SeriesType, SonarrHistoryData, SonarrHistoryEventType, + SonarrHistoryItem, SonarrRelease, SonarrTask, SonarrTaskName, }; use crate::models::{HorizontallyScrollableText, ScrollableText}; use bimap::BiMap; diff --git a/src/ui/lidarr_ui/library/album_details_ui.rs b/src/ui/lidarr_ui/library/album_details_ui.rs index e5dd920c..74c71fb5 100644 --- a/src/ui/lidarr_ui/library/album_details_ui.rs +++ b/src/ui/lidarr_ui/library/album_details_ui.rs @@ -1,9 +1,8 @@ use crate::app::App; use crate::models::Route; -use crate::models::lidarr_models::{ - DownloadRecord, DownloadStatus, LidarrHistoryItem, LidarrRelease, Track, -}; +use crate::models::lidarr_models::{DownloadRecord, LidarrHistoryItem, LidarrRelease, Track}; use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock}; +use crate::models::servarr_models::DownloadStatus; use crate::ui::lidarr_ui::library::track_details_ui::TrackDetailsUi; use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; diff --git a/src/ui/lidarr_ui/library/album_details_ui_tests.rs b/src/ui/lidarr_ui/library/album_details_ui_tests.rs index a277027b..a74df72c 100644 --- a/src/ui/lidarr_ui/library/album_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/album_details_ui_tests.rs @@ -6,10 +6,11 @@ mod tests { use strum::IntoEnumIterator; use crate::app::App; - use crate::models::lidarr_models::{DownloadRecord, DownloadStatus, Track}; + use crate::models::lidarr_models::{DownloadRecord, Track}; use crate::models::servarr_data::lidarr::lidarr_data::{ ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock, TRACK_DETAILS_BLOCKS, }; + use crate::models::servarr_models::DownloadStatus; use crate::models::stateful_table::StatefulTable; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::download_record; use crate::ui::DrawUi; diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index a50fc898..667a207a 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -1,9 +1,9 @@ use crate::app::App; use crate::logos::RADARR_LOGO; use crate::models::Route; -use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie, MovieStatus}; +use crate::models::radarr_models::{DownloadRecord, Movie, MovieStatus}; use crate::models::servarr_data::radarr::radarr_data::RadarrData; -use crate::models::servarr_models::{DiskSpace, RootFolder}; +use crate::models::servarr_models::{DiskSpace, DownloadStatus, RootFolder}; use crate::ui::DrawUi; use crate::ui::draw_tabs; use crate::ui::radarr_ui::blocklist::BlocklistUi; diff --git a/src/ui/radarr_ui/radarr_ui_tests.rs b/src/ui/radarr_ui/radarr_ui_tests.rs index 17b2dc75..b90e53f5 100644 --- a/src/ui/radarr_ui/radarr_ui_tests.rs +++ b/src/ui/radarr_ui/radarr_ui_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { - use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie, MovieStatus}; + use crate::models::radarr_models::{DownloadRecord, Movie, MovieStatus}; + use crate::models::servarr_models::DownloadStatus; use pretty_assertions::assert_eq; use ratatui::widgets::{Cell, Row}; use rstest::rstest; diff --git a/src/ui/readarr_ui/downloads/downloads_ui_tests.rs b/src/ui/readarr_ui/downloads/downloads_ui_tests.rs index 565b470a..17bddeb3 100644 --- a/src/ui/readarr_ui/downloads/downloads_ui_tests.rs +++ b/src/ui/readarr_ui/downloads/downloads_ui_tests.rs @@ -23,7 +23,8 @@ mod tests { use rstest::rstest; use serde_json::Number; - use crate::models::readarr_models::{DownloadRecord, DownloadStatus}; + use crate::models::readarr_models::DownloadRecord; + use crate::models::servarr_models::DownloadStatus; use crate::ui::ui_test_utils::test_utils::TerminalSize; use super::*; diff --git a/src/ui/sonarr_ui/library/episode_details_ui.rs b/src/ui/sonarr_ui/library/episode_details_ui.rs index ae939bc4..5e0ee30c 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui.rs @@ -1,10 +1,8 @@ use crate::app::App; use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS}; -use crate::models::servarr_models::Language; -use crate::models::sonarr_models::{ - DownloadRecord, DownloadStatus, Episode, SonarrHistoryItem, SonarrRelease, -}; +use crate::models::servarr_models::{DownloadStatus, Language}; +use crate::models::sonarr_models::{DownloadRecord, Episode, SonarrHistoryItem, SonarrRelease}; use crate::ui::sonarr_ui::sonarr_ui_utils::create_history_event_details; use crate::ui::styles::ManagarrStyle; use crate::ui::styles::{ diff --git a/src/ui/sonarr_ui/library/episode_details_ui_tests.rs b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs index 323a1d45..f0c476b6 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs @@ -8,7 +8,8 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, }; - use crate::models::sonarr_models::{DownloadRecord, DownloadStatus, Episode}; + use crate::models::servarr_models::DownloadStatus; + use crate::models::sonarr_models::{DownloadRecord, Episode}; use crate::ui::DrawUi; use crate::ui::sonarr_ui::library::episode_details_ui::{EpisodeDetailsUi, style_from_status}; use crate::ui::styles::{ diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index 1f968562..a746e7d7 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -1,9 +1,8 @@ use crate::app::App; use crate::models::Route; 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::servarr_models::DownloadStatus; +use crate::models::sonarr_models::{DownloadRecord, Episode, SonarrHistoryItem, SonarrRelease}; use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; use crate::ui::sonarr_ui::sonarr_ui_utils::create_history_event_details; use crate::ui::styles::ManagarrStyle; 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 f5754713..1d8d2923 100644 --- a/src/ui/sonarr_ui/library/season_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/season_details_ui_tests.rs @@ -10,7 +10,8 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, }; - use crate::models::sonarr_models::{DownloadRecord, DownloadStatus, Episode}; + use crate::models::servarr_models::DownloadStatus; + use crate::models::sonarr_models::{DownloadRecord, Episode}; use crate::models::stateful_table::StatefulTable; use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::download_record; use crate::ui::DrawUi; From bdeaf6ae353e80d0b5f7dc4a590455aaaf0a4528 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 18:44:34 -0600 Subject: [PATCH 154/170] test: prove the track, episode and movie row styles reach the rendered buffer The row-style decorators were unit-tested as pure functions and the populated test apps do carry a downloading queue entry, but text snapshots discard colour, so nothing proved the draw functions actually call the decorator with the live downloads list. Passing an empty slice instead left every test green. Render the lidarr tracks table, the sonarr episodes table and the radarr movies table into a Buffer and assert the probe row's foreground: downloading_style with the queue populated, missing_style once the queue is emptied. The probe is the second row so the selection highlight never masks its style. --- .../library/album_details_ui_tests.rs | 74 ++++++++++++++++++ src/ui/radarr_ui/library/library_ui_tests.rs | 74 ++++++++++++++++++ .../library/season_details_ui_tests.rs | 75 +++++++++++++++++++ 3 files changed, 223 insertions(+) diff --git a/src/ui/lidarr_ui/library/album_details_ui_tests.rs b/src/ui/lidarr_ui/library/album_details_ui_tests.rs index a74df72c..3ae7b035 100644 --- a/src/ui/lidarr_ui/library/album_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/album_details_ui_tests.rs @@ -138,6 +138,80 @@ mod tests { assert_eq!(style, row.downloaded()); } + mod test_track_row_styling { + use pretty_assertions::assert_eq; + use ratatui::style::Style; + + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::track; + use crate::ui::styles::{downloading_style, missing_style}; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_album_details_ui_renders_downloading_track_with_downloading_style() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumDetails.into()); + set_tracks_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected track"); + + assert_eq!(style.fg, downloading_style().fg); + } + + #[test] + fn test_album_details_ui_renders_missing_track_with_missing_style_when_queue_is_empty() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumDetails.into()); + app.data.lidarr_data.downloads.set_items(vec![]); + set_tracks_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected track"); + + assert_eq!(style.fg, missing_style().fg); + } + + fn set_tracks_with_unselected_probe(app: &mut App<'_>) { + let album_details_modal = app.data.lidarr_data.album_details_modal.as_mut().unwrap(); + album_details_modal.album_details_tabs.set_index(0); + album_details_modal.tracks.set_items(vec![ + track(), + Track { + has_file: false, + title: "Unselected track".to_owned(), + ..track() + }, + ]); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + AlbumDetailsUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; diff --git a/src/ui/radarr_ui/library/library_ui_tests.rs b/src/ui/radarr_ui/library/library_ui_tests.rs index 629c9555..5b7753cc 100644 --- a/src/ui/radarr_ui/library/library_ui_tests.rs +++ b/src/ui/radarr_ui/library/library_ui_tests.rs @@ -29,6 +29,80 @@ mod tests { }); } + mod test_movie_row_styling { + use pretty_assertions::assert_eq; + use ratatui::style::Style; + + use crate::models::radarr_models::Movie; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::movie; + use crate::ui::styles::{downloading_style, missing_style}; + use crate::ui::ui_test_utils::test_utils::create_test_terminal; + + use super::*; + + #[test] + fn test_library_ui_renders_downloading_movie_with_downloading_style() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + set_movies_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected movie"); + + assert_eq!(style.fg, downloading_style().fg); + } + + #[test] + fn test_library_ui_renders_missing_movie_with_missing_style_when_queue_is_empty() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.data.radarr_data.downloads.set_items(vec![]); + set_movies_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected movie"); + + assert_eq!(style.fg, missing_style().fg); + } + + fn set_movies_with_unselected_probe(app: &mut App<'_>) { + app.data.radarr_data.movies.set_items(vec![ + movie(), + Movie { + has_file: false, + monitored: true, + title: "Unselected movie".into(), + ..movie() + }, + ]); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + LibraryUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + } + mod snapshot_tests { use super::*; use crate::models::BlockSelectionState; 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 1d8d2923..1d8c1fc4 100644 --- a/src/ui/sonarr_ui/library/season_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/season_details_ui_tests.rs @@ -264,6 +264,81 @@ mod tests { assert_eq!(style, row.downloaded()); } + mod test_episode_row_styling { + use pretty_assertions::assert_eq; + use ratatui::style::Style; + + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::episode; + use crate::ui::styles::{downloading_style, missing_style}; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_season_details_ui_renders_downloading_episode_with_downloading_style() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + set_episodes_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected episode"); + + assert_eq!(style.fg, downloading_style().fg); + } + + #[test] + fn test_season_details_ui_renders_missing_episode_with_missing_style_when_queue_is_empty() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.data.sonarr_data.downloads.set_items(vec![]); + set_episodes_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected episode"); + + assert_eq!(style.fg, missing_style().fg); + } + + fn set_episodes_with_unselected_probe(app: &mut App<'_>) { + let season_details_modal = app.data.sonarr_data.season_details_modal.as_mut().unwrap(); + season_details_modal.season_details_tabs.set_index(0); + season_details_modal.episodes.set_items(vec![ + episode(), + Episode { + has_file: false, + monitored: true, + title: "Unselected episode".to_owned(), + ..episode() + }, + ]); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SeasonDetailsUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; From f2ee1e71f6d6e6231b3af2844049ad57bc2b01d5 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 18 Sep 2026 21:44:22 -0600 Subject: [PATCH 155/170] feat: show sizes under a gigabyte in megabytes Every size cell converted bytes to gigabytes and formatted the float in place, so anything below a gigabyte rendered as 0.00 GB. Books and tracks are almost always that small, which made the Size columns in Readarr and Lidarr useless. Replace convert_to_gb and convert_f64_to_gb with one format_size helper that renders gigabytes at or above 1 GiB and megabytes below it, at the precision each site already used. Every render site across the four servarr trees now calls it, including the two movie and episode detail strings built on the network side. Values at or above a gigabyte render byte for byte as before. Give the Readarr book statistics and book file fixtures and the Lidarr album statistics fixture realistic sub-gigabyte sizes so the megabyte path is pinned by the committed snapshots. --- .../servarr_data/readarr/readarr_data.rs | 4 ++-- .../albums/lidarr_albums_network_tests.rs | 2 +- .../lidarr_network_test_utils.rs | 4 ++-- src/network/radarr_network/library/mod.rs | 8 +++---- .../library/radarr_library_network_tests.rs | 2 +- .../sonarr_network/library/episodes/mod.rs | 6 +++--- src/ui/lidarr_ui/downloads/mod.rs | 6 +++--- src/ui/lidarr_ui/library/album_details_ui.rs | 6 +++--- src/ui/lidarr_ui/library/artist_details_ui.rs | 21 ++++++++----------- src/ui/lidarr_ui/library/mod.rs | 6 +++--- ...ders_AutomaticallySearchAlbumPrompt_2.snap | 4 ++-- ...ders_ManualAlbumSearchConfirmPrompt_2.snap | 4 ++-- ...renders_ManualAlbumSearchSortPrompt_2.snap | 4 ++-- ...m_details_renders_ManualAlbumSearch_2.snap | 4 ++-- ...tests__artist_details_ArtistDetails_0.snap | 2 +- ...ils_AutomaticallySearchArtistPrompt_0.snap | 2 +- ...ils_AutomaticallySearchArtistPrompt_2.snap | 4 ++-- ...ils_ManualArtistSearchConfirmPrompt_2.snap | 4 ++-- ...etails_ManualArtistSearchSortPrompt_2.snap | 4 ++-- ...__artist_details_ManualArtistSearch_2.snap | 4 ++-- ...s__artist_details_SearchAlbumsError_0.snap | 2 +- ..._tests__artist_details_SearchAlbums_0.snap | 2 +- ...t_details_UpdateAndScanArtistPrompt_0.snap | 2 +- ...t_details_UpdateAndScanArtistPrompt_2.snap | 4 ++-- ...tic_search_prompt_over_artist_details.snap | 2 +- ...lete_album_prompt_over_artist_details.snap | 2 +- ...e_and_scan_prompt_over_artist_details.snap | 2 +- ...dit_artist_renders_over_ArtistDetails.snap | 4 ++-- ...dit_artist_renders_over_ArtistHistory.snap | 2 +- ...rtist_renders_over_ManualArtistSearch.snap | 2 +- ...i_renders_artist_details_over_library.snap | 4 ++-- ...ui_renders_delete_artist_over_library.snap | 2 +- ..._over_edit_artist_over_artist_details.snap | 2 +- ..._ui_renders_update_all_artists_prompt.snap | 2 +- ...napshot_tests__lidarr_library_Artists.snap | 2 +- ...sts__lidarr_library_ArtistsSortPrompt.snap | 2 +- ...t_tests__lidarr_library_FilterArtists.snap | 2 +- ...ts__lidarr_library_FilterArtistsError.snap | 2 +- ...t_tests__lidarr_library_SearchArtists.snap | 2 +- ...ts__lidarr_library_SearchArtistsError.snap | 2 +- src/ui/lidarr_ui/mod.rs | 6 +++--- src/ui/lidarr_ui/root_folders/mod.rs | 6 +++--- ...__snapshot_tests__lidarr_tabs_Artists.snap | 2 +- src/ui/radarr_ui/downloads/mod.rs | 6 +++--- src/ui/radarr_ui/library/mod.rs | 6 +++--- src/ui/radarr_ui/library/movie_details_ui.rs | 6 +++--- ...ts__movie_details_render_ManualSearch.snap | 4 ++-- ...ails_render_ManualSearchConfirmPrompt.snap | 4 ++-- ...details_render_ManualSearchSortPrompt.snap | 4 ++-- ...Search_AutomaticallySearchMoviePrompt.snap | 4 ++-- ...nder_ManualSearch_UpdateAndScanPrompt.snap | 4 ++-- src/ui/radarr_ui/mod.rs | 6 +++--- src/ui/radarr_ui/root_folders/mod.rs | 6 +++--- src/ui/readarr_ui/downloads/mod.rs | 6 +++--- .../readarr_ui/library/author_details_ui.rs | 21 ++++++++----------- src/ui/readarr_ui/library/book_details_ui.rs | 10 ++++----- src/ui/readarr_ui/library/mod.rs | 6 +++--- ...tests__author_details_AuthorDetails_0.snap | 2 +- ...ils_AutomaticallySearchAuthorPrompt_0.snap | 2 +- ...ils_AutomaticallySearchAuthorPrompt_2.snap | 4 ++-- ...ils_ManualAuthorSearchConfirmPrompt_2.snap | 4 ++-- ...etails_ManualAuthorSearchSortPrompt_2.snap | 4 ++-- ...__author_details_ManualAuthorSearch_2.snap | 4 ++-- ...ts__author_details_SearchBooksError_0.snap | 2 +- ...t_tests__author_details_SearchBooks_0.snap | 2 +- ...r_details_UpdateAndScanAuthorPrompt_0.snap | 2 +- ...r_details_UpdateAndScanAuthorPrompt_2.snap | 4 ++-- ...tic_search_prompt_over_author_details.snap | 2 +- ...nders_delete_book_over_author_details.snap | 2 +- ...e_and_scan_prompt_over_author_details.snap | 2 +- ...nders_AutomaticallySearchBookPrompt_2.snap | 2 +- ...nders_AutomaticallySearchBookPrompt_3.snap | 4 ++-- ...__book_details_renders_BookFileInfo_2.snap | 2 +- ...etails_renders_DeleteBookFilePrompt_2.snap | 2 +- ...nders_ManualBookSearchConfirmPrompt_3.snap | 4 ++-- ..._renders_ManualBookSearchSortPrompt_3.snap | 4 ++-- ...ok_details_renders_ManualBookSearch_3.snap | 4 ++-- ...ry_ui_renders_add_author_over_library.snap | 2 +- ...i_renders_author_details_over_library.snap | 4 ++-- ...ui_renders_delete_author_over_library.snap | 2 +- ...y_ui_renders_edit_author_over_library.snap | 2 +- ..._ui_renders_update_all_authors_prompt.snap | 2 +- ...apshot_tests__readarr_library_Authors.snap | 2 +- ...ts__readarr_library_AuthorsSortPrompt.snap | 2 +- ..._tests__readarr_library_FilterAuthors.snap | 2 +- ...s__readarr_library_FilterAuthorsError.snap | 2 +- ..._tests__readarr_library_SearchAuthors.snap | 2 +- ...s__readarr_library_SearchAuthorsError.snap | 2 +- src/ui/readarr_ui/mod.rs | 6 +++--- src/ui/readarr_ui/root_folders/mod.rs | 6 +++--- ..._snapshot_tests__readarr_tabs_Authors.snap | 2 +- ..._tests__lidarr_ui_renders_library_tab.snap | 2 +- ...rr_ui_renders_library_tab_error_popup.snap | 2 +- ...arr_ui_renders_library_tab_with_error.snap | 2 +- ...ui_renders_notification_success_popup.snap | 2 +- ...tests__readarr_ui_renders_library_tab.snap | 2 +- ...rr_ui_renders_library_tab_error_popup.snap | 2 +- ...arr_ui_renders_library_tab_with_error.snap | 2 +- ...ui_renders_notification_failure_popup.snap | 2 +- ...ui_renders_notification_success_popup.snap | 2 +- src/ui/sonarr_ui/downloads/mod.rs | 6 +++--- .../sonarr_ui/library/episode_details_ui.rs | 6 +++--- src/ui/sonarr_ui/library/mod.rs | 6 +++--- src/ui/sonarr_ui/library/season_details_ui.rs | 10 ++++----- src/ui/sonarr_ui/library/series_details_ui.rs | 17 +++++++-------- ...ls_AutomaticallySearchEpisodePrompt_3.snap | 4 ++-- ...ls_ManualEpisodeSearchConfirmPrompt_3.snap | 4 ++-- ...tails_ManualEpisodeSearchSortPrompt_3.snap | 4 ++-- ...episode_details_ManualEpisodeSearch_3.snap | 4 ++-- ...ers_AutomaticallySearchSeasonPrompt_2.snap | 4 ++-- ...ers_ManualSeasonSearchConfirmPrompt_2.snap | 4 ++-- ...enders_ManualSeasonSearchSortPrompt_2.snap | 4 ++-- ..._details_renders_ManualSeasonSearch_2.snap | 4 ++-- src/ui/sonarr_ui/mod.rs | 6 +++--- src/ui/sonarr_ui/root_folders/mod.rs | 6 +++--- src/utils.rs | 14 +++++++------ src/utils_tests.rs | 20 ++++++++---------- 117 files changed, 241 insertions(+), 250 deletions(-) diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs index df0d8379..6d40006d 100644 --- a/src/models/servarr_data/readarr/readarr_data.rs +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -295,7 +295,7 @@ impl ReadarrData<'_> { statistics: Some(BookStatistics { book_file_count: 3, total_book_count: 7, - size_on_disk: 2469606195, + size_on_disk: 2457600, percent_of_books: 42.86, }), ..Book::default() @@ -538,7 +538,7 @@ impl ReadarrData<'_> { author_id: 1, book_id: 1, path: "/nfs/books/Test Author/Test Book.epub".to_owned(), - size: 3543348019, + size: 3355443, date_added: date, quality: quality.clone(), media_info: Some(MediaInfo { diff --git a/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs b/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs index 4023e4f5..bf7d1c34 100644 --- a/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs +++ b/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs @@ -34,7 +34,7 @@ mod tests { "trackFileCount": 8, "trackCount": 10, "totalTrackCount": 10, - "sizeOnDisk": 1024, + "sizeOnDisk": 317162578, "percentOfTracks": 99.9 } }]); diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index d35ad579..4d18199c 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -74,7 +74,7 @@ pub mod test_utils { "trackFileCount": 8, "trackCount": 10, "totalTrackCount": 10, - "sizeOnDisk": 1024, + "sizeOnDisk": 317162578, "percentOfTracks": 99.9 } }"#; @@ -250,7 +250,7 @@ pub mod test_utils { track_file_count: 8, track_count: 10, total_track_count: 10, - size_on_disk: 1024, + size_on_disk: 317162578, percent_of_tracks: 99.9, } } diff --git a/src/network/radarr_network/library/mod.rs b/src/network/radarr_network/library/mod.rs index 7bee027e..f17dfe94 100644 --- a/src/network/radarr_network/library/mod.rs +++ b/src/network/radarr_network/library/mod.rs @@ -11,7 +11,7 @@ use crate::models::stateful_table::StatefulTable; use crate::models::{Route, ScrollableText}; use crate::network::radarr_network::RadarrEvent; use crate::network::{Network, RequestMethod}; -use crate::utils::{convert_runtime, convert_to_gb}; +use crate::utils::{convert_runtime, format_size}; use anyhow::Result; use indoc::formatdoc; use log::{debug, info, warn}; @@ -319,7 +319,7 @@ impl Network<'_, '_> { .. } = movie_response; let (hours, minutes) = convert_runtime(runtime); - let size = convert_to_gb(size_on_disk); + let size = format_size(size_on_disk, 2); let studio = studio.clone().unwrap_or_default(); let quality_profile = app .data @@ -374,7 +374,7 @@ impl Network<'_, '_> { IMDB: {imdb_rating} Rotten Tomatoes: {rotten_tomatoes_rating} Quality Profile: {quality_profile} - Size: {size:.2} GB + Size: {size} Path: {path} Studio: {studio} Genres: {}", @@ -393,7 +393,7 @@ impl Network<'_, '_> { movie_details_modal.file_details = formatdoc!( "Relative Path: {} Absolute Path: {} - Size: {size:.2} GB + Size: {size} Date Added: {}", file.relative_path, file.path, diff --git a/src/network/radarr_network/library/radarr_library_network_tests.rs b/src/network/radarr_network/library/radarr_library_network_tests.rs index 97ca4491..bcafe528 100644 --- a/src/network/radarr_network/library/radarr_library_network_tests.rs +++ b/src/network/radarr_network/library/radarr_library_network_tests.rs @@ -759,7 +759,7 @@ mod tests { IMDB: Rotten Tomatoes: Quality Profile: HD - 1080p - Size: 0.00 GB + Size: 0.00 MB Path: /nfs/movies Studio: 21st Century Alex Genres: cool, family, fun" diff --git a/src/network/sonarr_network/library/episodes/mod.rs b/src/network/sonarr_network/library/episodes/mod.rs index 897e5ba9..25810653 100644 --- a/src/network/sonarr_network/library/episodes/mod.rs +++ b/src/network/sonarr_network/library/episodes/mod.rs @@ -8,7 +8,7 @@ use crate::models::sonarr_models::{ use crate::models::{Route, ScrollableText}; use crate::network::sonarr_network::SonarrEvent; use crate::network::{Network, RequestMethod}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use anyhow::Result; use indoc::formatdoc; use log::info; @@ -222,12 +222,12 @@ impl Network<'_, '_> { overview.unwrap_or_default(), )); if let Some(file) = episode_file { - let size = convert_to_gb(file.size); + let size = format_size(file.size, 2); episode_details_modal.file_details = formatdoc!( " Relative Path: {} Absolute Path: {} - Size: {size:.2} GB + Size: {size} Language: {} Date Added: {}", file.relative_path, diff --git a/src/ui/lidarr_ui/downloads/mod.rs b/src/ui/lidarr_ui/downloads/mod.rs index 004606da..0de74e66 100644 --- a/src/ui/lidarr_ui/downloads/mod.rs +++ b/src/ui/lidarr_ui/downloads/mod.rs @@ -12,7 +12,7 @@ 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::utils::convert_f64_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "downloads_ui_tests.rs"] @@ -97,12 +97,12 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } else { 1f64 - (*sizeleft / *size) }; - let file_size: f64 = convert_f64_to_gb(*size); + let file_size = format_size(*size as i64, 2); Row::new(vec![ Cell::from(title.to_owned()), Cell::from(format!("{:.0}%", percent * 100.0)), - Cell::from(format!("{file_size:.2} GB")), + Cell::from(file_size), Cell::from( output_path .as_ref() diff --git a/src/ui/lidarr_ui/library/album_details_ui.rs b/src/ui/lidarr_ui/library/album_details_ui.rs index 74c71fb5..46624d97 100644 --- a/src/ui/lidarr_ui/library/album_details_ui.rs +++ b/src/ui/lidarr_ui/library/album_details_ui.rs @@ -15,7 +15,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; use ratatui::prelude::{Line, Stylize, Text}; @@ -377,7 +377,7 @@ fn draw_album_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_lidarr_block != ActiveLidarrBlock::ManualAlbumSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -408,7 +408,7 @@ fn draw_album_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(quality_name), ]) diff --git a/src/ui/lidarr_ui/library/artist_details_ui.rs b/src/ui/lidarr_ui/library/artist_details_ui.rs index fe79a7ce..9f86c456 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui.rs @@ -26,7 +26,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use ratatui::layout::Alignment; use ratatui::text::Text; use serde_json::Number; @@ -223,7 +223,7 @@ fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]; if let Some(stats) = current_selection.statistics.as_ref() { - let size = convert_to_gb(stats.size_on_disk); + let size = format_size(stats.size_on_disk, 2); artist_description.extend(vec![ Line::from(vec![ "Albums: ".primary().bold(), @@ -235,7 +235,7 @@ fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]), Line::from(vec![ "Size on Disk: ".primary().bold(), - format!("{size:.2} GB").default_color(), + size.default_color(), ]), ]); } @@ -282,13 +282,10 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { || "N/A".to_owned(), |s| format!("{}/{}", s.track_file_count, s.total_track_count), ); - let size = album.statistics.as_ref().map_or_else( - || "N/A".to_owned(), - |s| { - let size = convert_to_gb(s.size_on_disk); - format!("{size:.2} GB") - }, - ); + let size = album + .statistics + .as_ref() + .map_or_else(|| "N/A".to_owned(), |s| format_size(s.size_on_disk, 2)); let duration_mins = album.duration / 60000; decorate_album_row_with_style( @@ -500,7 +497,7 @@ fn draw_artist_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_lidarr_block != ActiveLidarrBlock::ManualArtistSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -531,7 +528,7 @@ fn draw_artist_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(quality_name), ]) diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index 2100d8de..ed6584f1 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -13,7 +13,7 @@ use crate::ui::widgets::{ confirmation_prompt::ConfirmationPrompt, popup::{Popup, Size}, }; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use crate::{ app::App, models::{ @@ -104,7 +104,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let size = artist .statistics .as_ref() - .map_or(0f64, |stats| convert_to_gb(stats.size_on_disk)); + .map_or(0, |stats| stats.size_on_disk); let quality_profile = quality_profile_map .get_by_left(&artist.quality_profile_id) .cloned() @@ -140,7 +140,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(metadata_profile), Cell::from(albums.to_string()), Cell::from(tracks), - Cell::from(format!("{size:.2} GB")), + Cell::from(format_size(size, 2)), Cell::from(monitored.to_owned()), Cell::from(tags), ]), diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_AutomaticallySearchAlbumPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_AutomaticallySearchAlbumPrompt_2.snap index 70a533ed..bd30a682 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_AutomaticallySearchAlbumPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_AutomaticallySearchAlbumPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Tracks │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │ │ │ │ │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchConfirmPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchConfirmPrompt_2.snap index 6791f5c7..c6e6e8a3 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchConfirmPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchConfirmPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Tracks │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │ │ │ │ │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchSortPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchSortPrompt_2.snap index 9bfe80d5..43271405 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchSortPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchSortPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Tracks │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │ │ │ │ │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearch_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearch_2.snap index 9c738089..5406c4be 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearch_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearch_2.snap @@ -10,8 +10,8 @@ expression: output │ Tracks │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │ │ │ │ │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap index 651ed30a..1c6b39d2 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap index 1ff0a887..96959850 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ │ Release Date Size ││ - ││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap index 202d5722..2115191a 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap @@ -24,8 +24,8 @@ expression: output ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Releas│ │ 0.0 GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Releas│ │ 0.0 GB Lossless ││ + ││=> torrent 1 days ⛔ Test Releas│ │ 0.0 MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Releas│ │ 0.0 MB Lossless ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap index 1a8aa658..983b1e5e 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap @@ -24,8 +24,8 @@ expression: output ││ Albums │ History │ Manual Search │ │ ││ ││────────────────────────────────────────│Rejection reasons: │───────────────────────────────────────││ ││ Source ▼ Age ⛔ Title │• Unknown quality profile │e Peers Quality ││ - ││=> torrent 1 days ⛔ Test Relea│• Release is already mapped │ GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Relea│ │ GB Lossless ││ + ││=> torrent 1 days ⛔ Test Relea│• Release is already mapped │ MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Relea│ │ MB Lossless ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap index cd61dab9..8beb877d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap @@ -24,8 +24,8 @@ expression: output ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Source Age ⛔ Title Indexer Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless ││ + ││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap index 2b649fe3..46a9082a 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap @@ -24,8 +24,8 @@ expression: output ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Source ▼ Age ⛔ Title Indexer Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless ││ + ││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap index 756fe8e1..c65c57da 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap index 8e46d73d..be5daf72 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap index d112a74d..5b615091 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ │ Release Date Size ││ - ││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap index cb9bd135..96b7f3da 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap @@ -24,8 +24,8 @@ expression: output ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Releas│ │ 0.0 GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Releas│ │ 0.0 GB Lossless ││ + ││=> torrent 1 days ⛔ Test Releas│ │ 0.0 MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Releas│ │ 0.0 MB Lossless ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap index 1ff0a887..96959850 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ │ Release Date Size ││ - ││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap index 2a032e8c..f6ba7021 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ ╭───╮ │ Release Date Size ││ - ││=> 🏷 Test Album │ Delete Album Files: │ ✔ │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ Delete Album Files: │ ✔ │ │ 2023-01-01 302.47 MB ││ ││ │ ╰───╯ │ ││ ││ │ ╭───╮ │ ││ ││ │ Add List Exclusion: │ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap index d112a74d..5b615091 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ │ Release Date Size ││ - ││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap index 4757389b..a1f208d5 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ ││ Monitored Titl│ ╭─────────────────────────────────────────────────╮ │ize ││ - ││=> 🏷 Test│ Quality Profile: │Lossless ▼ │ │.00 GB ││ + ││=> 🏷 Test│ Quality Profile: │Lossless ▼ │ │02.47 MB ││ ││ │ ╰─────────────────────────────────────────────────╯ │ ││ ││ │ ╭─────────────────────────────────────────────────╮ │ ││ ││ │ Metadata Profile: │Standard ▼ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap index bfc2c4fe..87198105 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap index 71401ee8..18e0d3ab 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap index 28aacdbb..a36b629b 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ @@ -24,7 +24,7 @@ expression: output ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap index ee7c69bf..ca9ffab9 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap index 1955204e..1a02d863 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex 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 index 83c5301c..6c895a09 100644 --- 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 @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap index 45fd1574..6c07d7e7 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap @@ -4,4 +4,4 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap index 1c960b3f..bac43a58 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap index fea1362e..6230c0e2 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap index f02c2d86..0b6f6a3c 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap index 2bfaefbb..16a1fd84 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap index 0d39a1c7..06e0c83a 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/mod.rs b/src/ui/lidarr_ui/mod.rs index dbf277d4..a8fe835b 100644 --- a/src/ui/lidarr_ui/mod.rs +++ b/src/ui/lidarr_ui/mod.rs @@ -37,7 +37,7 @@ use crate::{ servarr_data::lidarr::lidarr_data::LidarrData, servarr_models::{DiskSpace, RootFolder}, }, - utils::convert_to_gb, + utils::format_size, }; mod blocklist; @@ -176,8 +176,8 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let RootFolder { path, free_space, .. } = &monitored_root_folders[i]; - let space: f64 = convert_to_gb(*free_space); - let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + let space = format_size(*free_space, 2); + let root_folder_space = Paragraph::new(format!("{path}: {space} free")) .block(borderless_block()) .default_color(); diff --git a/src/ui/lidarr_ui/root_folders/mod.rs b/src/ui/lidarr_ui/root_folders/mod.rs index ac2afa10..61603a0e 100644 --- a/src/ui/lidarr_ui/root_folders/mod.rs +++ b/src/ui/lidarr_ui/root_folders/mod.rs @@ -15,7 +15,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::utils::convert_to_gb; +use crate::utils::format_size; mod add_root_folder_ui; @@ -69,11 +69,11 @@ fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .. } = root_folders; - let space: f64 = convert_to_gb(*free_space); + let space = format_size(*free_space, 2); Row::new(vec![ Cell::from(path.to_owned()), - Cell::from(format!("{space:.2} GB")), + Cell::from(space), Cell::from( unmapped_folders .as_ref() diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap index bf284634..1140ba12 100644 --- a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap @@ -6,7 +6,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/downloads/mod.rs b/src/ui/radarr_ui/downloads/mod.rs index af147896..bdaf2615 100644 --- a/src/ui/radarr_ui/downloads/mod.rs +++ b/src/ui/radarr_ui/downloads/mod.rs @@ -12,7 +12,7 @@ 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::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "downloads_ui_tests.rs"] @@ -96,12 +96,12 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } else { 1f64 - (*sizeleft as f64 / *size as f64) }; - let file_size: f64 = convert_to_gb(*size); + let file_size = format_size(*size, 2); Row::new(vec![ Cell::from(title.to_owned()), Cell::from(format!("{:.0}%", percent * 100.0)), - Cell::from(format!("{file_size:.2} GB")), + Cell::from(file_size), Cell::from( output_path .as_ref() diff --git a/src/ui/radarr_ui/library/mod.rs b/src/ui/radarr_ui/library/mod.rs index 0b1af8d7..58669ce0 100644 --- a/src/ui/radarr_ui/library/mod.rs +++ b/src/ui/radarr_ui/library/mod.rs @@ -16,7 +16,7 @@ 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::utils::{convert_runtime, convert_to_gb}; +use crate::utils::{convert_runtime, format_size}; mod add_movie_ui; mod delete_movie_ui; @@ -95,7 +95,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let monitored = if movie.monitored { "🏷" } else { "" }; let studio = movie.studio.clone().unwrap_or_default(); let (hours, minutes) = convert_runtime(movie.runtime); - let file_size: f64 = convert_to_gb(movie.size_on_disk); + let file_size = format_size(movie.size_on_disk, 2); let certification = movie.certification.clone().unwrap_or_default(); let quality_profile = quality_profile_map .get_by_left(&movie.quality_profile_id) @@ -121,7 +121,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(format!("{hours}h {minutes}m")), Cell::from(certification), Cell::from(movie.original_language.name.to_owned()), - Cell::from(format!("{file_size:.2} GB")), + Cell::from(file_size), Cell::from(quality_profile), Cell::from(monitored.to_owned()), Cell::from(tags), diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index fa2f5903..ee38b7dd 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -26,7 +26,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::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "movie_details_ui_tests.rs"] @@ -398,7 +398,7 @@ fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && current_route != ActiveRadarrBlock::ManualSearchConfirmPrompt.into(), app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -434,7 +434,7 @@ fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(language), Cell::from(quality), diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch.snap index b66682e8..e9b04342 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchConfirmPrompt.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchConfirmPrompt.snap index e94e5ddc..dff44a15 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchConfirmPrompt.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchConfirmPrompt.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchSortPrompt.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchSortPrompt.snap index faf0a485..2f9e989d 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchSortPrompt.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchSortPrompt.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_AutomaticallySearchMoviePrompt.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_AutomaticallySearchMoviePrompt.snap index 9d72a3fc..812ce100 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_AutomaticallySearchMoviePrompt.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_AutomaticallySearchMoviePrompt.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_UpdateAndScanPrompt.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_UpdateAndScanPrompt.snap index 8be43539..d4f855aa 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_UpdateAndScanPrompt.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_UpdateAndScanPrompt.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 667a207a..7e2c9cc1 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -22,7 +22,7 @@ use crate::ui::utils::{ line_gauge_with_label, line_gauge_with_title, title_block, }; use crate::ui::widgets::loading_block::LoadingBlock; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use chrono::Duration; #[cfg(not(test))] use chrono::Utc; @@ -170,8 +170,8 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let RootFolder { path, free_space, .. } = &monitored_root_folders[i]; - let space: f64 = convert_to_gb(*free_space); - let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + let space = format_size(*free_space, 2); + let root_folder_space = Paragraph::new(format!("{path}: {space} free")) .block(borderless_block()) .default_color(); diff --git a/src/ui/radarr_ui/root_folders/mod.rs b/src/ui/radarr_ui/root_folders/mod.rs index 9c5ecfea..3fcadec9 100644 --- a/src/ui/radarr_ui/root_folders/mod.rs +++ b/src/ui/radarr_ui/root_folders/mod.rs @@ -12,7 +12,7 @@ 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, draw_input_box_popup, draw_popup}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "root_folders_ui_tests.rs"] @@ -66,11 +66,11 @@ fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .. } = root_folders; - let space: f64 = convert_to_gb(*free_space); + let space = format_size(*free_space, 2); Row::new(vec![ Cell::from(path.to_owned()), - Cell::from(format!("{space:.2} GB")), + Cell::from(space), Cell::from( unmapped_folders .as_ref() diff --git a/src/ui/readarr_ui/downloads/mod.rs b/src/ui/readarr_ui/downloads/mod.rs index ac1a7c6e..60863a51 100644 --- a/src/ui/readarr_ui/downloads/mod.rs +++ b/src/ui/readarr_ui/downloads/mod.rs @@ -12,7 +12,7 @@ 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::utils::convert_f64_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "downloads_ui_tests.rs"] @@ -97,12 +97,12 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } else { 1f64 - (*sizeleft / *size) }; - let file_size: f64 = convert_f64_to_gb(*size); + let file_size = format_size(*size as i64, 2); Row::new(vec![ Cell::from(title.to_owned()), Cell::from(format!("{:.0}%", percent * 100.0)), - Cell::from(format!("{file_size:.2} GB")), + Cell::from(file_size), Cell::from( output_path .as_ref() diff --git a/src/ui/readarr_ui/library/author_details_ui.rs b/src/ui/readarr_ui/library/author_details_ui.rs index 4d38f2d9..1b49197b 100644 --- a/src/ui/readarr_ui/library/author_details_ui.rs +++ b/src/ui/readarr_ui/library/author_details_ui.rs @@ -28,7 +28,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use ratatui::layout::Alignment; use ratatui::text::Text; use serde_json::Number; @@ -215,7 +215,7 @@ fn draw_author_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]; if let Some(stats) = current_selection.statistics.as_ref() { - let size = convert_to_gb(stats.size_on_disk); + let size = format_size(stats.size_on_disk, 2); author_description.extend(vec![ Line::from(vec![ "Books: ".primary().bold(), @@ -227,7 +227,7 @@ fn draw_author_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]), Line::from(vec![ "Size on Disk: ".primary().bold(), - format!("{size:.2} GB").default_color(), + size.default_color(), ]), ]); } @@ -278,13 +278,10 @@ fn draw_books_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { || "N/A".to_owned(), |s| format!("{}/{}", s.book_file_count, s.total_book_count), ); - let size = book.statistics.as_ref().map_or_else( - || "N/A".to_owned(), - |s| { - let size = convert_to_gb(s.size_on_disk); - format!("{size:.2} GB") - }, - ); + let size = book + .statistics + .as_ref() + .map_or_else(|| "N/A".to_owned(), |s| format_size(s.size_on_disk, 2)); decorate_book_row_with_style( book, @@ -483,7 +480,7 @@ fn draw_author_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_readarr_block != ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -514,7 +511,7 @@ fn draw_author_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(quality_name), ]) diff --git a/src/ui/readarr_ui/library/book_details_ui.rs b/src/ui/readarr_ui/library/book_details_ui.rs index a2b3b714..f34a9c2b 100644 --- a/src/ui/readarr_ui/library/book_details_ui.rs +++ b/src/ui/readarr_ui/library/book_details_ui.rs @@ -14,7 +14,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; use ratatui::prelude::{Line, Stylize, Text}; @@ -234,7 +234,7 @@ fn draw_book_file_info(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .. } = book_file; - let size = convert_to_gb(*size); + let size = format_size(*size, 2); let audio_info = media_info .as_ref() .map(|media_info| { @@ -249,7 +249,7 @@ fn draw_book_file_info(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Row::new(vec![ Cell::from(path.clone()), - Cell::from(format!("{size:.2} GB")), + Cell::from(size), Cell::from(quality.quality.name.clone()), Cell::from(audio_info), ]) @@ -395,7 +395,7 @@ fn draw_book_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_readarr_block != ActiveReadarrBlock::ManualBookSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -426,7 +426,7 @@ fn draw_book_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(quality_name), ]) diff --git a/src/ui/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs index 2f6a3655..8af624ee 100644 --- a/src/ui/readarr_ui/library/mod.rs +++ b/src/ui/readarr_ui/library/mod.rs @@ -13,7 +13,7 @@ use crate::ui::widgets::{ confirmation_prompt::ConfirmationPrompt, popup::{Popup, Size}, }; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use crate::{ app::App, models::{ @@ -103,7 +103,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let size = author .statistics .as_ref() - .map_or(0f64, |stats| convert_to_gb(stats.size_on_disk)); + .map_or(0, |stats| stats.size_on_disk); let quality_profile = quality_profile_map .get_by_left(&author.quality_profile_id) .cloned() @@ -134,7 +134,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(quality_profile), Cell::from(metadata_profile), Cell::from(books.to_string()), - Cell::from(format!("{size:.2} GB")), + Cell::from(format_size(size, 2)), Cell::from(monitored.to_owned()), Cell::from(tags), ]), diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap index e6343be1..6056c1d9 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.34 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap index b2da3535..3b9cae99 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 2.30 GB ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap index a333c344..5c3954f2 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap @@ -22,8 +22,8 @@ expression: output ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Torren│ │ 0.0 GB 2 / 1 EPUB ││ - ││ usenet 1 days ⛔ Test Usenet│ │ 0.0 GB EPUB ││ + ││=> torrent 1 days ⛔ Test Torren│ │ 0.0 MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet│ │ 0.0 MB EPUB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap index 1c097c0f..58a0be00 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap @@ -22,8 +22,8 @@ expression: output ││ Books │ History │ Author Search │ Torrent Release? │ ││ ││────────────────────────────────────────│ │───────────────────────────────────────││ ││ Source ▼ Age ⛔ Title │ │e Peers Quality ││ - ││=> torrent 1 days ⛔ Test Torre│Rejection reasons: │ GB 2 / 1 EPUB ││ - ││ usenet 1 days ⛔ Test Usene│• Unknown quality profile │ GB EPUB ││ + ││=> torrent 1 days ⛔ Test Torre│Rejection reasons: │ MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usene│• Unknown quality profile │ MB EPUB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap index 36480ec8..7bd3d5aa 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap @@ -22,8 +22,8 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Source Age ⛔ Title Indexer Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB ││ - ││ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB ││ + ││=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap index 98da33f3..9b9afa69 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap @@ -22,8 +22,8 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Source ▼ Age ⛔ Title Indexer Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB ││ - ││ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB ││ + ││=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap index 61b74938..54c2b491 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.34 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap index 69d3bec3..d4d1bdf6 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.34 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap index f6ed5de7..a68e61e9 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 2.30 GB ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap index 57bc41c4..abd34e3b 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap @@ -22,8 +22,8 @@ expression: output ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Torren│ │ 0.0 GB 2 / 1 EPUB ││ - ││ usenet 1 days ⛔ Test Usenet│ │ 0.0 GB EPUB ││ + ││=> torrent 1 days ⛔ Test Torren│ │ 0.0 MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet│ │ 0.0 MB EPUB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap index b2da3535..3b9cae99 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 2.30 GB ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap index 97cb8bbc..595aca7a 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │ Do you really want to delete the book: │ ││ ││─────────────────────────────────────────│ Test Book? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 2.30 GB ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ ││ │ ╭───╮ │ ││ ││ │ Delete Book Files: │ ✔ │ │ ││ ││ │ ╰───╯ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap index f6ed5de7..a68e61e9 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ ││ Monitored Title │ │Date Size ││ - ││=> 🏷 Test Book │ │20 2.30 GB ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap index 3cfc96fa..56d7d96b 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap @@ -10,7 +10,7 @@ expression: output │ Editions │ History │ File │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Path Size Quality Media Info │ - │=> /nfs/books/Test Author/Test Book.epub 3.30 GB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ + │=> /nfs/books/Test Author/Test Book.epub 3.20 MB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap index a41752fe..feb16d8d 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap @@ -10,8 +10,8 @@ expression: output │ Editions │ History │ File │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB │ - │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap index 17c7120e..0b9b0686 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap @@ -10,7 +10,7 @@ expression: output │ Editions │ History │ File │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Path Size Quality Media Info │ - │=> /nfs/books/Test Author/Test Book.epub 3.30 GB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ + │=> /nfs/books/Test Author/Test Book.epub 3.20 MB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap index c594e320..8125a1df 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap @@ -10,7 +10,7 @@ expression: output │ Editions │ History │ File │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Path Size Quality Media Info │ - │=> /nfs/books/Test Author/Test Book.epub 3.30 GB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ + │=> /nfs/books/Test Author/Test Book.epub 3.20 MB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap index 39997ba8..069bd165 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap @@ -10,8 +10,8 @@ expression: output │ Editions │ History │ File │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB │ - │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap index cf700c01..fcd51d70 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap @@ -10,8 +10,8 @@ expression: output │ Editions │ History │ File │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB │ - │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap index d94fdc1b..c1290151 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap @@ -10,8 +10,8 @@ expression: output │ Editions │ History │ File │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 GB 2 / 1 EPUB │ - │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 GB EPUB │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB │ │ │ │ │ │ │ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap index ffe0ab6c..b0dadcd4 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap index 37368057..fe2d10c2 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Author: Test Author │ │Status: Continuing │ @@ -22,7 +22,7 @@ expression: output ││ Books │ History │ Author Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Files Release Date Size ││ - ││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.34 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap index b81749cd..de77d3b3 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap index 914bcf22..7309c501 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap index df266ef9..6a07d373 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap index 17605ff3..160bda7d 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap @@ -4,4 +4,4 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap index 21b5d910..671ab115 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap index f41de851..52b83327 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap index 82929938..4ac7e1e4 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap index b473f952..0c70718d 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap index bf0cd322..cf31bc39 100644 --- a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags -=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs index 3b6b7712..23ce5dee 100644 --- a/src/ui/readarr_ui/mod.rs +++ b/src/ui/readarr_ui/mod.rs @@ -37,7 +37,7 @@ use crate::{ servarr_data::readarr::readarr_data::ReadarrData, servarr_models::{DiskSpace, RootFolder}, }, - utils::convert_to_gb, + utils::format_size, }; mod blocklist; @@ -176,8 +176,8 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let RootFolder { path, free_space, .. } = &monitored_root_folders[i]; - let space: f64 = convert_to_gb(*free_space); - let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + let space = format_size(*free_space, 2); + let root_folder_space = Paragraph::new(format!("{path}: {space} free")) .block(borderless_block()) .default_color(); diff --git a/src/ui/readarr_ui/root_folders/mod.rs b/src/ui/readarr_ui/root_folders/mod.rs index c99469e0..8fa93f8f 100644 --- a/src/ui/readarr_ui/root_folders/mod.rs +++ b/src/ui/readarr_ui/root_folders/mod.rs @@ -15,7 +15,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::utils::convert_to_gb; +use crate::utils::format_size; mod add_root_folder_ui; @@ -69,11 +69,11 @@ fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .. } = root_folders; - let space: f64 = convert_to_gb(*free_space); + let space = format_size(*free_space, 2); Row::new(vec![ Cell::from(path.to_owned()), - Cell::from(format!("{space:.2} GB")), + Cell::from(space), Cell::from( unmapped_folders .as_ref() diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap index 3287bf78..2f9793fb 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap @@ -6,7 +6,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap index 42749d4e..8a709b87 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap index 23f6c68a..a6922ef9 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ │ u update all │ │ │───────────────────────────────────│ enter details │─────────────────────────────────────│ │ Name ▼ Typ│ esc cancel filter │e Monitored Tags │ -│=> Alex Per│ ↑ k scroll up │0 GB 🏷 alex │ +│=> Alex Per│ ↑ k scroll up │1 MB 🏷 alex │ │ │ ↓ j scroll down │ │ │ │ ← h previous tab │ │ │ │ → l next tab │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap index 3e81a80f..544110f3 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap @@ -22,7 +22,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap index cec6243f..6ed014c7 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap index 1c1261ed..06339f5d 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap index af4527c1..dadc6114 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ │ u update all │ │ │───────────────────────────────────│ enter details │─────────────────────────────────────│ │ Name ▼ │ esc cancel filter │e Monitored Tags │ -│=> Test Author │ ↑ k scroll up │0 GB 🏷 alex │ +│=> Test Author │ ↑ k scroll up │0 MB 🏷 alex │ │ │ ↓ j scroll down │ │ │ │ ← h previous tab │ │ │ │ → l next tab │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap index 19225520..87e67a20 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap @@ -22,7 +22,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap index 8ea9985c..1baa65ab 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap index ebd2dc03..1916849e 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ -│=> Test Author Continuing Standard Comprehensive 0 0.00 GB 🏷 alex │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/downloads/mod.rs b/src/ui/sonarr_ui/downloads/mod.rs index bf46b5bc..a1a0ba1d 100644 --- a/src/ui/sonarr_ui/downloads/mod.rs +++ b/src/ui/sonarr_ui/downloads/mod.rs @@ -12,7 +12,7 @@ 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::utils::convert_f64_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "downloads_ui_tests.rs"] @@ -97,12 +97,12 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } else { 1f64 - (*sizeleft / *size) }; - let file_size: f64 = convert_f64_to_gb(*size); + let file_size = format_size(*size as i64, 2); Row::new(vec![ Cell::from(title.to_owned()), Cell::from(format!("{:.0}%", percent * 100.0)), - Cell::from(format!("{file_size:.2} GB")), + Cell::from(file_size), Cell::from( output_path .as_ref() diff --git a/src/ui/sonarr_ui/library/episode_details_ui.rs b/src/ui/sonarr_ui/library/episode_details_ui.rs index 5e0ee30c..80a8d77f 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui.rs @@ -19,7 +19,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use chrono::Utc; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; @@ -414,7 +414,7 @@ fn draw_episode_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_sonarr_block != ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -454,7 +454,7 @@ fn draw_episode_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(language), Cell::from(quality), diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index eb179001..e44ea2c0 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -12,7 +12,7 @@ use crate::ui::widgets::{ confirmation_prompt::ConfirmationPrompt, popup::{Popup, Size}, }; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use crate::{ app::App, models::{ @@ -104,7 +104,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let size = series .statistics .as_ref() - .map_or(0f64, |stats| convert_to_gb(stats.size_on_disk)); + .map_or(0, |stats| stats.size_on_disk); let quality_profile = quality_profile_map .get_by_left(&series.quality_profile_id) .expect("Quality profile ID must exist in quality_profile_map") @@ -134,7 +134,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(series.series_type.to_display_str()), Cell::from(quality_profile), Cell::from(language_profile), - Cell::from(format!("{size:.2} GB")), + Cell::from(format_size(size, 2)), Cell::from(monitored.to_owned()), Cell::from(tags), ]), diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index a746e7d7..a06931b3 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -16,7 +16,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use chrono::Utc; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; @@ -188,7 +188,7 @@ fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { }; let episode_monitored = if episode.monitored { "🏷" } else { "" }; - let size = convert_to_gb(size_on_disk); + let size = format_size(size_on_disk, 2); let air_date = if let Some(air_date) = air_date_utc.as_ref() { air_date.to_string() } else { @@ -203,7 +203,7 @@ fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(episode_number.to_string()), Cell::from(title.clone()), Cell::from(air_date), - Cell::from(format!("{size:.2} GB")), + Cell::from(size), Cell::from(quality_profile), ]), ) @@ -378,7 +378,7 @@ fn draw_season_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_sonarr_block != ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -418,7 +418,7 @@ fn draw_season_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(language), Cell::from(quality), diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 4170cf7d..36601f4c 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -26,7 +26,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "series_details_ui_tests.rs"] @@ -200,10 +200,10 @@ fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]), ]; if let Some(stats) = current_selection.statistics.as_ref() { - let size = convert_to_gb(stats.size_on_disk); + let size = format_size(stats.size_on_disk, 2); series_description.extend(vec![Line::from(vec![ "Size on Disk: ".primary().bold(), - format!("{size:.2} GB").default_color(), + size.default_color(), ])]); } @@ -234,13 +234,10 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { || "N/A".to_owned(), |s| format!("{}/{}", s.episode_file_count, s.episode_count), ); - let size = season.statistics.as_ref().map_or_else( - || "N/A".to_owned(), - |s| { - let size = convert_to_gb(s.size_on_disk); - format!("{size:.2} GB") - }, - ); + let size = season + .statistics + .as_ref() + .map_or_else(|| "N/A".to_owned(), |s| format_size(s.size_on_disk, 2)); decorate_season_row_with_style( season, diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_AutomaticallySearchEpisodePrompt_3.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_AutomaticallySearchEpisodePrompt_3.snap index 19969df8..34949968 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_AutomaticallySearchEpisodePrompt_3.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_AutomaticallySearchEpisodePrompt_3.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p│ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p│ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p│ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p│ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchConfirmPrompt_3.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchConfirmPrompt_3.snap index 5822cba7..7660cf10 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchConfirmPrompt_3.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchConfirmPrompt_3.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p│ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p│ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p│ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p│ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchSortPrompt_3.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchSortPrompt_3.snap index 2c176471..423dff02 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchSortPrompt_3.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchSortPrompt_3.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p│ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p│ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p│ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p│ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearch_3.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearch_3.snap index cb7aac14..4914a70c 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearch_3.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearch_3.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p│ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p│ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p│ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p│ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_AutomaticallySearchSeasonPrompt_2.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_AutomaticallySearchSeasonPrompt_2.snap index 4dcfb7ee..935783b9 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_AutomaticallySearchSeasonPrompt_2.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_AutomaticallySearchSeasonPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Episodes │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchConfirmPrompt_2.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchConfirmPrompt_2.snap index b5d18146..ec4c5072 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchConfirmPrompt_2.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchConfirmPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Episodes │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchSortPrompt_2.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchSortPrompt_2.snap index 58928493..e2c528fe 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchSortPrompt_2.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchSortPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Episodes │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearch_2.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearch_2.snap index 8f62f051..10e836d4 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearch_2.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearch_2.snap @@ -10,8 +10,8 @@ expression: output │ Episodes │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index bb5e45cb..c3242df1 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -37,7 +37,7 @@ use crate::{ servarr_models::{DiskSpace, RootFolder}, sonarr_models::DownloadRecord, }, - utils::convert_to_gb, + utils::format_size, }; mod blocklist; @@ -177,8 +177,8 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let RootFolder { path, free_space, .. } = &monitored_root_folders[i]; - let space: f64 = convert_to_gb(*free_space); - let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + let space = format_size(*free_space, 2); + let root_folder_space = Paragraph::new(format!("{path}: {space} free")) .block(borderless_block()) .default_color(); diff --git a/src/ui/sonarr_ui/root_folders/mod.rs b/src/ui/sonarr_ui/root_folders/mod.rs index db98c247..c0029f29 100644 --- a/src/ui/sonarr_ui/root_folders/mod.rs +++ b/src/ui/sonarr_ui/root_folders/mod.rs @@ -12,7 +12,7 @@ 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, draw_input_box_popup, draw_popup}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "root_folders_ui_tests.rs"] @@ -67,11 +67,11 @@ fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .. } = root_folders; - let space: f64 = convert_to_gb(*free_space); + let space = format_size(*free_space, 2); Row::new(vec![ Cell::from(path.to_owned()), - Cell::from(format!("{space:.2} GB")), + Cell::from(space), Cell::from( unmapped_folders .as_ref() diff --git a/src/utils.rs b/src/utils.rs index 33a05929..4500292b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -79,12 +79,14 @@ pub fn init_logging_config() -> log4rs::Config { .unwrap() } -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 format_size(bytes: i64, precision: usize) -> String { + if bytes >= 1024i64.pow(3) { + let gb = bytes as f64 / 1024f64.powi(3); + format!("{gb:.precision$} GB") + } else { + let mb = bytes as f64 / 1024f64.powi(2); + format!("{mb:.precision$} MB") + } } pub fn convert_runtime(runtime: i64) -> (i64, i64) { diff --git a/src/utils_tests.rs b/src/utils_tests.rs index 9434f80c..ab871ad9 100644 --- a/src/utils_tests.rs +++ b/src/utils_tests.rs @@ -3,20 +3,18 @@ mod tests { use std::fs::{self, File}; use std::io::{BufRead, BufReader, Seek, SeekFrom, Write}; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; - use crate::utils::{convert_f64_to_gb, convert_runtime, convert_to_gb, was_log_rotated}; + use crate::utils::{convert_runtime, format_size, was_log_rotated}; #[test] - fn test_convert_to_gb() { - assert_eq!(convert_to_gb(2147483648), 2f64); - 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); + fn test_format_size() { + assert_str_eq!(format_size(2_457_600, 2), "2.34 MB"); + assert_str_eq!(format_size(2_469_606_195, 2), "2.30 GB"); + assert_str_eq!(format_size(1_073_741_824, 2), "1.00 GB"); + assert_str_eq!(format_size(1_073_741_823, 2), "1024.00 MB"); + assert_str_eq!(format_size(0, 2), "0.00 MB"); + assert_str_eq!(format_size(6_710_886, 1), "6.4 MB"); } #[test] From a4b8a2342424f7319b1bb387184f631d9d8f14c9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sat, 19 Sep 2026 13:57:49 -0600 Subject: [PATCH 156/170] docs: list the Readarr features in the README --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9497ecee..a14a8793 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,23 @@ Key: ### Readarr -- [ ] Support for Readarr +| TUI | CLI | Feature | +|-----|-----|----------------------------------------------------------------------------------------------------------------| +| ✅ | ✅ | View your library, downloads, blocklist, editions | +| ✅ | ✅ | View details of a specific author, book, or edition including description, history, downloaded file info | +| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library | +| ✅ | ✅ | Add authors to your library | +| ✅ | ✅ | Delete authors, books, downloads, indexers, root folders, and book files | +| ✅ | ✅ | Trigger automatic searches for authors or books | +| ✅ | ✅ | Trigger refresh and disk scan for authors and downloads | +| ✅ | ✅ | Manually search for author releases or books | +| ✅ | ✅ | Edit your authors 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 | ### Whisparr From a222f40616d3016b6eb88a658393a7f3cbd7088c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 28 Sep 2026 14:49:53 -0600 Subject: [PATCH 157/170] fmt: addressed linter complaints --- src/event/key_tests.rs | 2 +- .../radarr_handlers/library/add_movie_handler_tests.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/event/key_tests.rs b/src/event/key_tests.rs index 8ef6d4c8..986f5c08 100644 --- a/src/event/key_tests.rs +++ b/src/event/key_tests.rs @@ -24,7 +24,7 @@ mod tests { #[case(Key::Char('q'), "q")] #[case(Key::Ctrl('q'), "ctrl-q")] fn test_key_formatter(#[case] key: Key, #[case] expected_str: &str) { - assert_str_eq!(format!("{key}"), format!("{expected_str}")); + assert_str_eq!(format!("{key}"), expected_str.to_string()); } #[test] 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 2a49c611..a078831b 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -768,7 +768,7 @@ mod tests { mod test_handle_submit { use crate::assert_navigation_popped; use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::{ - add_movie_body, add_movie_search_result, collection_movie, + add_movie_search_result, collection_movie, }; use crate::models::BlockSelectionState; use crate::models::radarr_models::Movie; @@ -1336,7 +1336,7 @@ mod tests { use crate::{ assert_navigation_popped, handlers::radarr_handlers::radarr_handler_test_utils::utils::{ - add_movie_body, add_movie_search_result, collection_movie, + add_movie_search_result, collection_movie, }, models::{ BlockSelectionState, From ab7542651b6ca05a112c380afc40207315e5f9d6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 28 Sep 2026 15:53:26 -0600 Subject: [PATCH 158/170] chore: bump GitHub Actions to Node 24 runtimes --- .github/workflows/check.yml | 10 +++---- .github/workflows/release.yml | 56 +++++++++++++++++------------------ .github/workflows/test.yml | 10 +++---- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e7848f84..7a0aa037 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -46,7 +46,7 @@ jobs: toolchain: [stable, beta] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -54,7 +54,7 @@ jobs: components: clippy - name: Run clippy action - uses: clechasseur/rs-clippy-check@v3 + uses: clechasseur/rs-clippy-check@v5 - name: Cache Cargo dependencies uses: Swatinem/rust-cache@v2 @@ -65,7 +65,7 @@ jobs: name: nightly / doc runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Rust nightly uses: dtolnay/rust-toolchain@nightly @@ -79,7 +79,7 @@ jobs: name: 1.95.0 / check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install 1.95.0 uses: dtolnay/rust-toolchain@master diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e89bfb1..3e9bc214 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,19 +32,19 @@ jobs: - name: Checkout repository if: env.ACT != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: ssh-key: ${{ secrets.RELEASE_BOT_SSH_KEY }} fetch-depth: 0 - name: Checkout repository if: env.ACT == 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: '3.10' @@ -219,13 +219,13 @@ jobs: git push origin --follow-tags - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: path: artifacts - name: Upload the changed Cargo files (Act) if: env.ACT == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: bumped-cargo-files path: | @@ -267,7 +267,7 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 @@ -279,18 +279,18 @@ jobs: - name: Get bumped Cargo files (Act) if: env.ACT == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: bumped-cargo-files path: ${{ github.workspace }} - - uses: actions/cache@v3 + - uses: actions/cache@v5 name: Cache Cargo registry with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} - - uses: actions/cache@v3 + - uses: actions/cache@v5 if: startsWith(matrix.job.name, 'linux-') with: path: ~/.cargo/bin @@ -375,7 +375,7 @@ jobs: cp target/${{ matrix.job.target }}/release/${{ env.RELEASE_NAME }}.sha256 artifacts/ - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: artifacts-${{ env.RELEASE_NAME }} path: artifacts @@ -393,12 +393,12 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts merge-multiple: true @@ -421,7 +421,7 @@ jobs: - name: Create a GitHub Release if: env.ACT != 'true' - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -457,7 +457,7 @@ jobs: prerelease: false - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: path: artifacts overwrite: true @@ -474,12 +474,12 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 - name: Get release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts merge-multiple: true @@ -528,12 +528,12 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 - name: Get release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts merge-multiple: true @@ -592,12 +592,12 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 - name: Get release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts merge-multiple: true @@ -618,20 +618,20 @@ jobs: echo "Release version: ${{ env.version }}" - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub if: env.ACT != 'true' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Push to Docker Hub - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile @@ -651,13 +651,13 @@ jobs: exit 1 - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Get bumped Cargo files (Act) if: env.ACT == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: bumped-cargo-files path: ${{ github.workspace }} @@ -668,13 +668,13 @@ jobs: git fetch --all git pull - - uses: actions/cache@v3 + - uses: actions/cache@v5 name: Cache Cargo registry with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} - - uses: actions/cache@v3 + - uses: actions/cache@v5 with: path: ~/.cargo/bin key: ${{ runner.os }}-cargo-bin-${{ hashFiles('.github/workflows/release.yml') }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 226c2a9c..9ccc1307 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: # toolchain toolchain: [stable, beta] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install ${{ matrix.toolchain }} uses: dtolnay/rust-toolchain@master @@ -74,7 +74,7 @@ jobs: # runs-on: ubuntu-latest # name: ubuntu / stable / minimal-versions # steps: - # - uses: actions/checkout@v4 + # - uses: actions/checkout@v5 # - name: Install Rust stable # uses: dtolnay/rust-toolchain@stable @@ -110,7 +110,7 @@ jobs: # - run: vcpkg install openssl:x64-windows-static-md # if: runner.os == 'Windows' - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -151,7 +151,7 @@ jobs: name: ubuntu / stable / coverage steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -176,7 +176,7 @@ jobs: - name: Upload to codecov.io if: env.ACT != 'true' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From 424ed622292da3fe335483dfd2e566b4aa30756e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 28 Sep 2026 16:02:28 -0600 Subject: [PATCH 159/170] chore: update dependencies ahead of release --- Cargo.lock | 940 +++++++++++++++++++++++++++-------------------------- 1 file changed, 473 insertions(+), 467 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ca21474..e94e6cc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -34,9 +34,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -108,9 +108,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -152,13 +152,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -184,9 +184,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -194,14 +194,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -225,6 +226,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bimap" version = "0.6.3" @@ -272,9 +279,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -296,9 +303,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -319,15 +326,15 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cargo-husky" @@ -346,9 +353,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -358,15 +365,26 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] [[package]] name = "chrono" @@ -384,9 +402,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -394,9 +412,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -407,30 +425,30 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -458,9 +476,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -490,7 +508,7 @@ dependencies = [ "lazy_static", "serde", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -508,9 +526,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ "encode_unicode", "libc", @@ -552,6 +570,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -561,6 +585,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -573,7 +606,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "crossterm_winapi", "mio", "parking_lot", @@ -589,13 +622,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix 1.1.4", + "rustix 1.1.5", "signal-hook", "signal-hook-mio", "winapi", @@ -663,12 +696,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -682,7 +715,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -696,20 +729,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -720,7 +753,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -731,18 +764,18 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -787,7 +820,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -800,7 +833,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -864,7 +897,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -872,13 +905,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -904,9 +937,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encode_unicode" @@ -916,11 +949,16 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -931,7 +969,7 @@ checksum = "583f1f514d2754010ff71ed6853068cacbe43cc142cc076aa1b871d9754efc48" dependencies = [ "darling 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -940,7 +978,7 @@ version = "0.6.1" dependencies = [ "darling 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -989,17 +1027,11 @@ dependencies = [ "regex", ] -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filedescriptor" @@ -1014,15 +1046,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "finl_unicode" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" [[package]] name = "fixedbitset" @@ -1083,24 +1115,24 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1109,26 +1141,26 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" @@ -1138,9 +1170,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -1179,11 +1211,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1193,8 +1223,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1205,15 +1238,15 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -1273,9 +1306,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1283,9 +1316,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1293,9 +1326,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1334,15 +1367,15 @@ dependencies = [ [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -1362,9 +1395,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1377,16 +1410,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1426,9 +1460,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1440,9 +1474,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1453,9 +1487,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1467,16 +1501,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1487,15 +1522,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1535,9 +1570,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1571,7 +1606,7 @@ version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ - "console 0.16.3", + "console 0.16.6", "once_cell", "similar", "tempfile", @@ -1579,22 +1614,22 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "indoc", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_terminal_polyfill" @@ -1629,7 +1664,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -1644,7 +1679,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1663,24 +1698,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1695,7 +1730,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -1712,9 +1747,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -1724,20 +1759,20 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] [[package]] name = "line-clipping" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] @@ -1754,9 +1789,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -1775,9 +1810,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "serde_core", ] @@ -1805,12 +1840,12 @@ dependencies = [ "log-mdc", "mock_instant", "parking_lot", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde-value", "serde_json", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.21", "thread-id", "typemap-ors", "unicode-segmentation", @@ -1819,18 +1854,18 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.0" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ "hashbrown 0.17.1", ] [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "mac_address" @@ -1907,9 +1942,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmem" @@ -1949,9 +1984,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -1988,7 +2023,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2008,7 +2043,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand 0.9.4", + "rand 0.9.5", "regex", "serde_json", "serde_urlencoded", @@ -2016,13 +2051,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -2035,7 +2076,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -2074,7 +2115,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2116,7 +2157,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] @@ -2162,7 +2203,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "foreign-types", "libc", @@ -2178,7 +2219,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2229,26 +2270,35 @@ dependencies = [ [[package]] name = "palette" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ "approx", - "fast-srgb8", "libm", "palette_derive", + "palette_math", ] [[package]] name = "palette_derive" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", ] [[package]] @@ -2282,9 +2332,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", "ucd-trie", @@ -2292,9 +2342,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" dependencies = [ "pest", "pest_generator", @@ -2302,25 +2352,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" dependencies = [ "pest", - "sha2", ] [[package]] @@ -2350,7 +2399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -2363,7 +2412,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2383,21 +2432,21 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2465,9 +2514,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2480,9 +2529,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.13.0", + "bitflags 2.13.2", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -2499,9 +2548,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -2511,7 +2560,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -2519,21 +2568,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.3", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -2541,23 +2591,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2576,23 +2626,34 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2618,6 +2679,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -2649,7 +2725,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "compact_str", "critical-section", "hashbrown 0.17.1", @@ -2659,7 +2735,7 @@ dependencies = [ "palette", "serde", "strum 0.28.0", - "thiserror 2.0.18", + "thiserror 2.0.21", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -2714,7 +2790,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "hashbrown 0.17.1", "indoc", "instability", @@ -2734,7 +2810,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] @@ -2750,9 +2826,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2762,9 +2838,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2785,11 +2861,11 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "encoding_rs", "futures-core", @@ -2863,21 +2939,21 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-ident", ] [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2894,7 +2970,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2903,11 +2979,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -2916,9 +2992,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -2942,9 +3018,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2952,9 +3028,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -2973,15 +3049,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -2991,9 +3067,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -3043,7 +3119,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3068,9 +3144,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3088,29 +3164,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3175,7 +3251,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3185,7 +3261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3228,9 +3304,9 @@ dependencies = [ [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -3250,9 +3326,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -3262,15 +3338,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3322,7 +3398,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3334,7 +3410,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3356,9 +3432,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3376,13 +3463,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -3405,7 +3492,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3429,7 +3516,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -3439,9 +3526,9 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "parking_lot", - "rustix 1.1.4", + "rustix 1.1.5", "signal-hook", "windows-sys 0.61.2", ] @@ -3452,7 +3539,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -3490,8 +3577,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", - "bitflags 2.13.0", + "base64 0.22.1", + "bitflags 2.13.2", "fancy-regex", "filedescriptor", "finl_unicode", @@ -3536,11 +3623,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -3551,18 +3638,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -3577,9 +3664,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -3598,9 +3685,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -3608,24 +3695,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3640,20 +3718,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -3661,22 +3739,23 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "serde_core", "serde_spanned", @@ -3695,9 +3774,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", "toml_datetime", @@ -3707,18 +3786,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -3741,7 +3820,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -3819,9 +3898,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -3905,9 +3984,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "atomic", "getrandom 0.4.3", @@ -3922,7 +4001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40630259c022600210096da9538abcb992b801e30b464cb9d19f19ef0e0d09b9" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3931,7 +4010,7 @@ version = "0.6.1" dependencies = [ "log", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3958,7 +4037,7 @@ checksum = "5b2d5567b6fbd34e8f0488d56b648e67c0d999535f4af2060d14f9074b43e833" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4021,9 +4100,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4034,19 +4113,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4054,31 +4134,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -4096,9 +4176,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -4259,7 +4339,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4270,7 +4350,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4324,7 +4404,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4333,16 +4413,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4360,31 +4431,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4402,101 +4456,53 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -4509,9 +4515,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yansi" @@ -4532,34 +4538,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4573,13 +4579,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] @@ -4591,9 +4597,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -4602,9 +4608,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -4613,17 +4619,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From 37f697d1b40e348ce9ecad57802a3046d47a7265 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 29 Sep 2026 13:47:04 -0600 Subject: [PATCH 160/170] docs: reformatted tables in README --- README.md | 126 +++++++++++++++++++++++++++--------------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index a14a8793..4232cd23 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,8 @@ Key: | Symbol | Status | |--------|-----------| -| ✅ | Supported | -| ❌ | Missing | +| ✅ | Supported | +| ❌ | Missing | | 🕒 | Planned | | 🚫 | Won't Add | @@ -143,82 +143,82 @@ Key: | TUI | CLI | Feature | |-----|-----|----------------------------------------------------------------------------------------------------------------| -| ✅ | ✅ | View your library, downloads, collections, and blocklist | -| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits | -| ✅ | ✅ | View details of any collection and the movies in them | -| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | -| ✅ | ✅ | Search your library or collections | -| ✅ | ✅ | Add movies to your library | -| ✅ | ✅ | Delete movies, downloads, and indexers | -| ✅ | ✅ | Trigger automatic searches for movies | -| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections | -| ✅ | ✅ | Manually search for movies | -| ✅ | ✅ | Edit your movies, collections, 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, collections, and blocklist | +| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits | +| ✅ | ✅ | View details of any collection and the movies in them | +| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library or collections | +| ✅ | ✅ | Add movies to your library | +| ✅ | ✅ | Delete movies, downloads, and indexers | +| ✅ | ✅ | Trigger automatic searches for movies | +| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections | +| ✅ | ✅ | Manually search for movies | +| ✅ | ✅ | Edit your movies, collections, 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 | ### Sonarr | 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 | -| ✅ | ✅ | 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 | ### Lidarr | TUI | CLI | Feature | |-----|-----|----------------------------------------------------------------------------------------------------------------| -| ✅ | ✅ | View your library, downloads, blocklist, tracks | -| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info | -| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | -| ✅ | ✅ | Search your library | -| ✅ | ✅ | Add artists to your library | -| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files | -| ✅ | ✅ | Trigger automatic searches for artists or albums | -| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads | -| ✅ | ✅ | Manually search for full artist discographies or albums | -| ✅ | ✅ | Edit your artists 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, tracks | +| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info | +| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library | +| ✅ | ✅ | Add artists to your library | +| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files | +| ✅ | ✅ | Trigger automatic searches for artists or albums | +| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads | +| ✅ | ✅ | Manually search for full artist discographies or albums | +| ✅ | ✅ | Edit your artists 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 | TUI | CLI | Feature | |-----|-----|----------------------------------------------------------------------------------------------------------------| -| ✅ | ✅ | View your library, downloads, blocklist, editions | -| ✅ | ✅ | View details of a specific author, book, or edition including description, history, downloaded file info | -| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | -| ✅ | ✅ | Search your library | -| ✅ | ✅ | Add authors to your library | -| ✅ | ✅ | Delete authors, books, downloads, indexers, root folders, and book files | -| ✅ | ✅ | Trigger automatic searches for authors or books | -| ✅ | ✅ | Trigger refresh and disk scan for authors and downloads | -| ✅ | ✅ | Manually search for author releases or books | -| ✅ | ✅ | Edit your authors 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, editions | +| ✅ | ✅ | View details of a specific author, book, or edition including description, history, downloaded file info | +| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library | +| ✅ | ✅ | Add authors to your library | +| ✅ | ✅ | Delete authors, books, downloads, indexers, root folders, and book files | +| ✅ | ✅ | Trigger automatic searches for authors or books | +| ✅ | ✅ | Trigger refresh and disk scan for authors and downloads | +| ✅ | ✅ | Manually search for author releases or books | +| ✅ | ✅ | Edit your authors 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 | ### Whisparr From 238348f6ec49ee3bcf215afc2fb87556cb58c6e9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 29 Sep 2026 13:49:28 -0600 Subject: [PATCH 161/170] fix(radarr): render the movie overview one size down so the collection details stay visible behind it --- .../collections/collection_details_ui.rs | 2 +- ...ts__snapshot_tests__ViewMovieOverview.snap | 68 +++++++++---------- ...ils_ui_renders_movie_overview_loading.snap | 68 +++++++++---------- ...vie_overview_when_the_modal_is_absent.snap | 68 +++++++++---------- ...ls_ui_renders_scrolled_movie_overview.snap | 68 +++++++++---------- 5 files changed, 137 insertions(+), 137 deletions(-) diff --git a/src/ui/radarr_ui/collections/collection_details_ui.rs b/src/ui/radarr_ui/collections/collection_details_ui.rs index 5713a9b2..eda54eaf 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui.rs @@ -46,7 +46,7 @@ impl DrawUi for CollectionDetailsUi { draw_popup(f, app, draw_collection_details, Size::Large); if context_option.unwrap_or(active_radarr_block) == ActiveRadarrBlock::ViewMovieOverview { - draw_popup(f, app, draw_movie_overview, Size::Large); + draw_popup(f, app, draw_movie_overview, Size::Medium); } } } diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap index 0ff98b61..fb30709a 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap @@ -8,40 +8,40 @@ expression: output - ╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │Collection movie blah blah blah │ - │ │ - │It was shot in Madison, Wisconsin: a city the cast never really left. │ - │ │ - │The sequel took seven years to finish. │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │ + │Monitored: Y│Collection movie blah blah blah │ │ + │Search on Ad│ │ │ + │ │It was shot in Madison, Wisconsin: a city the cast never really left. │ │ + │ │ │ │ + │ │The sequel took seven years to finish. │ │ + │ Movies ──│ │───────────│ + │ ✔ Title │ │ │ + │=> ✔ Test │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap index e3a3c612..dada3b57 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap @@ -8,40 +8,40 @@ expression: output - ╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ │ - │ │ - │ Loading ... │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │ + │Monitored: Y│ │ │ + │Search on Ad│ │ │ + │ │ Loading ... │ │ + │ │ │ │ + │ │ │ │ + │ Movies ──│ │───────────│ + │ │ │ │ + │ │ │ │ + │ Loading ...│ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap index e3a3c612..07ec0757 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap @@ -8,40 +8,40 @@ expression: output - ╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ │ - │ │ - │ Loading ... │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │ + │Monitored: Y│ │ │ + │Search on Ad│ │ │ + │ │ Loading ... │ │ + │ │ │ │ + │ │ │ │ + │ Movies ──│ │───────────│ + │ ✔ Title │ │ │ + │=> ✔ Test │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap index 365f8fcc..c3b95b16 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap @@ -8,40 +8,40 @@ expression: output - ╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │It was shot in Madison, Wisconsin: a city the cast never really left. │ - │ │ - │The sequel took seven years to finish. │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │ + │Monitored: Y│It was shot in Madison, Wisconsin: a city the cast never really left. │ │ + │Search on Ad│ │ │ + │ │The sequel took seven years to finish. │ │ + │ │ │ │ + │ │ │ │ + │ Movies ──│ │───────────│ + │ ✔ Title │ │ │ + │=> ✔ Test │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ From 4b8b9cde577cce511ce5310fba878c9c96adda4a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 29 Sep 2026 14:24:00 -0600 Subject: [PATCH 162/170] docs: Updated README with screenshots of Readarr --- README.md | 9 ++++++++- screenshots/lidarr/lidarr_library.png | Bin 191174 -> 198781 bytes screenshots/readarr/author_details.png | Bin 0 -> 142630 bytes screenshots/readarr/book_details.png | Bin 0 -> 122699 bytes screenshots/readarr/manual_author_search.png | Bin 0 -> 264003 bytes screenshots/readarr/manual_book_search.png | Bin 0 -> 168123 bytes screenshots/readarr/readarr_library.png | Bin 0 -> 190930 bytes 7 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 screenshots/readarr/author_details.png create mode 100644 screenshots/readarr/book_details.png create mode 100644 screenshots/readarr/manual_author_search.png create mode 100644 screenshots/readarr/manual_book_search.png create mode 100644 screenshots/readarr/readarr_library.png diff --git a/README.md b/README.md index 4232cd23..4fdf7cd0 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ To see all available commands, simply run `managarr --help`: ```shell $ managarr --help -managarr 0.7.0 +managarr 0.8.0 Alex Clarke A TUI and CLI to manage your Servarrs @@ -590,6 +590,13 @@ Managarr supports using environment variables on startup so you don't have to al ![artist_discography_search](screenshots/lidarr/artist_discography_search.png) ![manual_album_search](screenshots/lidarr/manual_album_search.png) +### Readarr +![readarr_library](screenshots/readarr/readarr_library.png) +![author_details](screenshots/readarr/author_details.png) +![book_details](screenshots/readarr/book_details.png) +![manual_author_search](screenshots/readarr/manual_author_search.png) +![manual_album_search](screenshots/readarr/manual_book_search.png) + ### General ![logs](screenshots/radarr/logs.png) ![indexers](screenshots/radarr/indexers.png) diff --git a/screenshots/lidarr/lidarr_library.png b/screenshots/lidarr/lidarr_library.png index e1f7c608a13ab9d38fae1fd05ebabebb8fbacc9a..0f9dc0c41944226ca593b5b16c8446e8ac2780fb 100644 GIT binary patch literal 198781 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

k?dP1saJhS5+H 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 VLyVl7%b^3B?hPaDlI!-*{sX9vF-QOa literal 191174 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

nTsU)DvJ);Q$X8T&g@b1f> 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 diff --git a/screenshots/readarr/author_details.png b/screenshots/readarr/author_details.png new file mode 100644 index 0000000000000000000000000000000000000000..5dc323ad4b1d4eae878a4760077bebe86b3a5a9f GIT binary patch literal 142630 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ξ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%0A3v zPkvo_OODMqg|g0qFzzyDa?U~!=Cg~tcj^6$BD>%H;|0gydoXjt`<&M~_V2Njf=w3Q z!*KIijK7TP^ujz>9&cn}-uov%KHGXw=NCxxZr`2ByRlfNtXvVaMYEhvMi`!xou8Mn zBz||SaBfWsarTgw*Y`O>-$XQXioSl>UMQogO%TSD+H}**&sRQ^XGDHg825ajyo@(f z|1UhYn2Ehd;k_u_pX{D>+dd^Wo?Y2}E~Mq2JNa3iw)I;&opIYn%%3&yyejh zORtS*=iy(2P=gsBdfuB!KH3)b!|J=ji?%Z-yr=yr+AEI@+b`Q}j_3W<&E$KdksYf` zcJ2Kzdml>fP2t=D^v2uFX*Om~D=GK*_@t$kl)cr|wP@x4JG+LZo4$^VI@c+^_tp5e zHgBygA4-$nC;3tFYx%yK6Fe zhfLbP^*k2ra>8f0KYHesf4wXH-+=zF(qVlLYcz_1RVS*0v<}VWMXCQK;!YoNgw|^| z&E$fHb}=aZBab}N3Y|`{lz0~kfezgyiFdMD*V)7?ZQQwuWJ5M)H-^+Fl4G1o;nAyxVF%Uo5CnEUHr7sai7=se%1Zn|~Q%cmfK%^`Zx`Ql|268T!= z(?+7A7;i7Pf^ETWd1BX^GWO-2ytE`pmWiqof*XkwkP5WZR!mj-LqXGzPw6VJp^X?+ zjyeLaY?be^v6f9&$CTeNf3X5?Y5Cq<<;Jgz}AXu?M5yzIx*C@i;XX~9Ia)nLOY4p?)u4IBB(F&+PE@dR$_2|ZKlTy*Dlsh`1eicSkW{{x9)Y3 z=&F0>*SoRj`b-=?F=qmAH^4Po1G&ceD4+^|6%(rx^Pjpbs; zQ<1PiUe@Ri8`;IMczPS{-7>gFcsIOw?ywnL)*|6`8wT$F4?Es|RCPMmC4ZK(XQu~+ z)k&w0ZGt+j)Y%iJk#|Oq7-1)J6rp%&DT=EFGI6~SK1*!cHttp~c!l_4SY^2jW$Mz& zs0z1vapiNgMav4blM~}+a;n?um?9CkZADxsR5Xxuuvfqxc3Mk?VP2!MbHjU`);F1u7#<6c_fQ`HOz4}2 zPuaPBD79;3VcGk~*@fr!;Ma>mehPL~^wEip; z;d3t9Ee(8|Na!Sgw@U5Yb66~oDBAa)=Tm-u;dd1w_=FnFu&}~nQ^NXW;!jRlfzb8g zhPHL_^BtBaj!+a!g|uE#Z`=$+ILyUc_rG_4Lfa~=n5q0(!B*!Mh1i*r3%Ko6Jha!q z9xDpDsfe59K{n0VU!PF8wb*Nv3AlQnXK9vECjM5^5dJ+@dB?MIb?xl^$%WhOn0MTv zx@K}>!VcKbIe8jkx_!`f*7aBWYjV>4PO1dl@Hwu5%~$ukemhkb#z^ zd-&&u$13r~+SKQZ6L5Qv_nU>=Lvo(8Eymdg&%QUlMmC6NyqH!b@_FT}>2E*H(Eo@b z6*eR!X!ch&6UytXb|PKawg^$XAf0JD*mgcBv~gT`qx<)47)BiZD9igG!gF%6-${DU zvuluDhtMt$uQ+a>8B4bv)$?~APa3Z8e*VHd_szFYo_24T6Xf)J({cCC>3PQ8d1Yk^ z(g@4eJwKyxPH;T4DHD9s@Lob~W*DS#OMIKKqLh5gc6%zxTPM#~cupOv@*kG19Cuwp zaV*1%mzs^634F~=;0x_);KX$Q9{I;Y`w$6*8WiAcLlL$Ptz4kZ$OYV~T)<7QfjgcF zxD`U~oM5j}oPcYurFT6`u{LR0H5n_VWTv&!b;7OckpXclUz#+jJodBeixqGu>_CUM rM0C4qXei)@&u~4$--r7iBjEl&i5F#UwtsI%00000NkvXXu0mjfkAz@+ literal 0 HcmV?d00001 diff --git a/screenshots/readarr/book_details.png b/screenshots/readarr/book_details.png new file mode 100644 index 0000000000000000000000000000000000000000..e0d57b3a32a40e6b672eeb75a69438ff6d9b1eae GIT binary patch literal 122699 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 PaQULqugLSafB$~~9fkkv literal 0 HcmV?d00001 diff --git a/screenshots/readarr/manual_author_search.png b/screenshots/readarr/manual_author_search.png new file mode 100644 index 0000000000000000000000000000000000000000..b8c018152a8bfc732a6fe8c2cfb3bb3d99754664 GIT binary patch literal 264003 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ξ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'(hLq!Jh6 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