Compare commits

...

27 Commits

Author SHA1 Message Date
Dark-Alex-17 4abf705cb5 fix: Added the correct keybinding context to the Lidarr edit artist popup 2026-01-08 09:28:12 -07:00
Dark-Alex-17 cf98b10d77 style: Modified the LineGauge style so its more readable in the UI 2026-01-07 17:19:32 -07:00
Dark-Alex-17 f0ed71b436 build: Upgraded to Ratatui v0.30.0 and fixed a new security vulnerability [#13] 2026-01-07 17:15:54 -07:00
Dark-Alex-17 243de47cae feat: Initial Lidarr support for searching for new artists 2026-01-07 15:53:18 -07:00
Dark-Alex-17 d3947d9e15 fix: Improved fault tolerance for search result tables and test all indexer results tables 2026-01-07 14:58:32 -07:00
Dark-Alex-17 64d8c65831 fix: Prevented additional empty slice errors in indexer tables 2026-01-07 14:09:12 -07:00
Dark-Alex-17 60c4cf1098 fix: Fixed a bug in all Servarr implementations to not try to get the current selection of a search table when an error is returned from the API 2026-01-07 14:00:02 -07:00
Dark-Alex-17 9cc3ccb419 feat: Lidarr CLI commands to list quality profiles and metadata profiles 2026-01-07 13:15:50 -07:00
Dark-Alex-17 45c61369c8 feat: Improved CLI readability by creating a separate Global Options section for global flags 2026-01-07 13:08:23 -07:00
Dark-Alex-17 a8609e08c5 feat: CLI support for deleting a tag in Lidarr 2026-01-07 12:50:23 -07:00
Dark-Alex-17 a18b047f4f feat: Lidarr CLI support for listing and adding tags 2026-01-07 12:20:39 -07:00
Dark-Alex-17 b1afdaf541 feat: Added CLI and TUI support for editing Lidarr artists 2026-01-07 12:01:03 -07:00
Dark-Alex-17 3c1634d1e3 testing 2026-01-07 10:45:49 -07:00
Dark-Alex-17 9b4eda6a9d feat: Support for updating all Lidarr artists in both the CLI and TUI 2026-01-06 12:47:10 -07:00
Dark-Alex-17 96308afeee feat: Added Lidarr CLI support for fetching the host config and the security config 2026-01-06 11:00:19 -07:00
Dark-Alex-17 4e13d5d34d style: Applied formatting for the lidarr_command_tests 2026-01-06 10:25:05 -07:00
Dark-Alex-17 b4a99d1665 feat: Created Lidarr commands: 'get artist-details' and 'get system-status' 2026-01-06 10:24:51 -07:00
Dark-Alex-17 a012f6ecd5 feat: Fetch the artist members as part of the artist details query 2026-01-06 10:10:28 -07:00
Dark-Alex-17 5afee1998b feat: Support for toggling the monitoring of a given artist via the CLI and TUI 2026-01-06 09:40:16 -07:00
Dark-Alex-17 059fa48bd9 style: Applied uniform formatting across all new Lidarr files 2026-01-05 15:46:16 -07:00
Dark-Alex-17 6771a0ab38 feat: Full support for deleting an artist via CLI and TUI 2026-01-05 15:44:51 -07:00
Dark-Alex-17 bc3aeefa6e feat: TUI support for Lidarr library 2026-01-05 13:10:30 -07:00
Dark-Alex-17 e61537942b test: Implemented tests for the Lidarr list artists command 2026-01-05 11:28:35 -07:00
Dark-Alex-17 5d09b2402c feat: CLI support for listing artists 2026-01-05 10:58:48 -07:00
Dark-Alex-17 368f7505ff feat: Improved UI speed and responsiveness
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2025-12-19 13:41:14 -07:00
Dark-Alex-17 6a9fd0999c build: Added additional compile-time optimizations for speed improvements and a smaller binary 2025-12-17 16:45:51 -07:00
Alex Clarke d8ac94d067 Merge pull request #51 from Dark-Alex-17/test-refactoring
Refactored all tests and wrote snapshot tests to finally cover the UI module
2025-12-16 16:41:04 -07:00
198 changed files with 14382 additions and 1097 deletions
Generated
+1037 -397
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -42,7 +42,7 @@ strum = { version = "0.26.3", features = ["derive"] }
strum_macros = "0.26.4" strum_macros = "0.26.4"
tokio = { version = "1.44.2", features = ["full"] } tokio = { version = "1.44.2", features = ["full"] }
tokio-util = "0.7.8" tokio-util = "0.7.8"
ratatui = { version = "0.29.0", features = [ ratatui = { version = "0.30.0", features = [
"all-widgets", "all-widgets",
"unstable-widget-ref", "unstable-widget-ref",
] } ] }
@@ -59,7 +59,7 @@ ctrlc = "3.4.5"
colored = "3.0.0" colored = "3.0.0"
async-trait = "0.1.83" async-trait = "0.1.83"
dirs-next = "2.0.0" dirs-next = "2.0.0"
managarr-tree-widget = "0.24.0" managarr-tree-widget = "0.25.0"
indicatif = "0.17.9" indicatif = "0.17.9"
derive_setters = "0.1.6" derive_setters = "0.1.6"
deunicode = "1.6.0" deunicode = "1.6.0"
@@ -91,4 +91,6 @@ name = "managarr"
[profile.release] [profile.release]
lto = true lto = true
codegen-units = 1 codegen-units = 3
opt-level = "s"
strip = true
+25
View File
@@ -8,6 +8,7 @@ mod tests {
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::app::{App, AppConfig, Data, ServarrConfig, interpolate_env_vars}; 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::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::models::{HorizontallyScrollableText, TabRoute};
@@ -35,6 +36,7 @@ mod tests {
theme: None, theme: None,
radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]), radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]),
sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]), sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]),
lidarr: None,
}; };
let expected_tab_routes = vec![ let expected_tab_routes = vec![
TabRoute { TabRoute {
@@ -80,6 +82,7 @@ mod tests {
assert_eq!(app.tick_until_poll, 400); assert_eq!(app.tick_until_poll, 400);
assert_eq!(app.ticks_until_scroll, 4); assert_eq!(app.ticks_until_scroll, 4);
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
assert_eq!(app.ui_scroll_tick_count, 0);
assert!(!app.is_loading); assert!(!app.is_loading);
assert!(!app.is_routing); assert!(!app.is_routing);
assert!(!app.should_refresh); assert!(!app.should_refresh);
@@ -183,6 +186,7 @@ mod tests {
..SonarrData::default() ..SonarrData::default()
}; };
let data = Data { let data = Data {
lidarr_data: LidarrData::default(),
radarr_data, radarr_data,
sonarr_data, sonarr_data,
}; };
@@ -240,6 +244,27 @@ mod tests {
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
} }
#[test]
fn test_on_ui_scroll_tick() {
let mut app = App {
ticks_until_scroll: 1,
..App::default()
};
assert_eq!(app.ui_scroll_tick_count, 0);
assert_eq!(app.tick_count, 0);
app.on_ui_scroll_tick();
assert_eq!(app.ui_scroll_tick_count, 1);
assert_eq!(app.tick_count, 0);
app.on_ui_scroll_tick();
assert_eq!(app.ui_scroll_tick_count, 0);
assert_eq!(app.tick_count, 0);
}
#[tokio::test] #[tokio::test]
async fn test_on_tick_first_render() { async fn test_on_tick_first_render() {
let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500); let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
+2
View File
@@ -1,5 +1,6 @@
use crate::app::App; use crate::app::App;
use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding}; 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::radarr::radarr_context_clues::RadarrContextClueProvider;
use crate::app::sonarr::sonarr_context_clues::SonarrContextClueProvider; use crate::app::sonarr::sonarr_context_clues::SonarrContextClueProvider;
use crate::models::Route; use crate::models::Route;
@@ -21,6 +22,7 @@ impl ContextClueProvider for ServarrContextClueProvider {
match app.get_current_route() { match app.get_current_route() {
Route::Radarr(_, _) => RadarrContextClueProvider::get_context_clues(app), Route::Radarr(_, _) => RadarrContextClueProvider::get_context_clues(app),
Route::Sonarr(_, _) => SonarrContextClueProvider::get_context_clues(app), Route::Sonarr(_, _) => SonarrContextClueProvider::get_context_clues(app),
Route::Lidarr(_, _) => LidarrContextClueProvider::get_context_clues(app),
_ => None, _ => None,
} }
} }
+60
View File
@@ -0,0 +1,60 @@
use crate::app::App;
use crate::app::context_clues::{BARE_POPUP_CONTEXT_CLUES, ContextClue, ContextClueProvider};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::models::Route;
use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock};
#[cfg(test)]
#[path = "lidarr_context_clues_tests.rs"]
mod lidarr_context_clues_tests;
pub static ARTISTS_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_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "edit search"),
];
pub(in crate::app) struct LidarrContextClueProvider;
impl ContextClueProvider for LidarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
let Route::Lidarr(active_lidarr_block, _context_option) = app.get_current_route() else {
panic!("LidarrContextClueProvider::get_context_clues called with non-Lidarr route");
};
match active_lidarr_block {
ActiveLidarrBlock::AddArtistSearchInput | ActiveLidarrBlock::AddArtistEmptySearchResults => {
Some(&BARE_POPUP_CONTEXT_CLUES)
}
_ if EDIT_ARTIST_BLOCKS.contains(&active_lidarr_block) => {
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
_ if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) => {
Some(&ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES)
}
_ => app
.data
.lidarr_data
.main_tabs
.get_active_route_contextual_help(),
}
}
}
@@ -0,0 +1,172 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClueProvider,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::lidarr::lidarr_context_clues::{
ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider,
};
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS};
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use rstest::rstest;
#[test]
fn test_artists_context_clues() {
let mut artists_context_clues_iter = ARTISTS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc
)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, "update all")
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(artists_context_clues_iter.next());
}
#[test]
fn test_add_artist_search_results_context_clues() {
let mut add_artist_search_results_context_clues_iter =
ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
add_artist_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
add_artist_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "edit search")
);
assert_none!(add_artist_search_results_context_clues_iter.next());
}
#[test]
#[should_panic(
expected = "LidarrContextClueProvider::get_context_clues called with non-Lidarr route"
)]
fn test_lidarr_context_clue_provider_get_context_clues_non_lidarr_route() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::default().into());
LidarrContextClueProvider::get_context_clues(&mut app);
}
#[test]
fn test_lidarr_context_clue_provider_artists_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_artists_sort_prompt_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::ArtistsSortPrompt.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_search_artists_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::SearchArtists.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_filter_artists_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::FilterArtists.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[rstest]
fn test_lidarr_context_clue_provider_bare_popup_context_clues(
#[values(
ActiveLidarrBlock::AddArtistSearchInput,
ActiveLidarrBlock::AddArtistEmptySearchResults
)]
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, &BARE_POPUP_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_confirmation_prompt_popup_clues_edit_indexer_blocks() {
for active_lidarr_block in EDIT_ARTIST_BLOCKS {
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_add_artist_search_results_context_clues() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES);
}
}
+63
View File
@@ -0,0 +1,63 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::network::NetworkEvent;
use crate::network::lidarr_network::LidarrEvent;
use pretty_assertions::{assert_eq, assert_str_eq};
use tokio::sync::mpsc;
#[tokio::test]
async fn test_dispatch_by_lidarr_block_artists() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::Artists)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::ListArtists.into());
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_lidarr_block_add_artist_search_results() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.network_tx = Some(tx);
app.data.lidarr_data.add_artist_search = Some("test artist".into());
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AddArtistSearchResults)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::SearchNewArtist("test artist".to_owned()).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_extract_add_new_artist_search_query() {
let app = App::test_default_fully_populated();
let query = app.extract_add_new_artist_search_query().await;
assert_str_eq!(query, "Test Artist");
}
}
+115
View File
@@ -0,0 +1,115 @@
use crate::{
models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock,
network::lidarr_network::LidarrEvent,
};
use super::App;
pub mod lidarr_context_clues;
#[cfg(test)]
#[path = "lidarr_tests.rs"]
mod lidarr_tests;
impl App<'_> {
pub(super) async fn dispatch_by_lidarr_block(&mut self, active_lidarr_block: &ActiveLidarrBlock) {
match active_lidarr_block {
ActiveLidarrBlock::Artists => {
self
.dispatch_network_event(LidarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetMetadataProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(LidarrEvent::ListArtists.into())
.await;
}
ActiveLidarrBlock::AddArtistSearchResults => {
self
.dispatch_network_event(
LidarrEvent::SearchNewArtist(self.extract_add_new_artist_search_query().await).into(),
)
.await;
}
_ => (),
}
self.check_for_lidarr_prompt_action().await;
self.reset_tick_count();
}
async fn extract_add_new_artist_search_query(&self) -> String {
self
.data
.lidarr_data
.add_artist_search
.as_ref()
.expect("add_artist_search should be set")
.text
.clone()
}
async fn check_for_lidarr_prompt_action(&mut self) {
if self.data.lidarr_data.prompt_confirm {
self.data.lidarr_data.prompt_confirm = false;
if let Some(lidarr_event) = self.data.lidarr_data.prompt_confirm_action.take() {
self.dispatch_network_event(lidarr_event.into()).await;
self.should_refresh = true;
}
}
}
pub(super) async fn lidarr_on_tick(&mut self, active_lidarr_block: ActiveLidarrBlock) {
if self.is_first_render {
self.refresh_lidarr_metadata().await;
self.dispatch_by_lidarr_block(&active_lidarr_block).await;
self.is_first_render = false;
return;
}
if self.should_refresh {
self.dispatch_by_lidarr_block(&active_lidarr_block).await;
self.refresh_lidarr_metadata().await;
}
if self.is_routing {
if !self.should_refresh {
self.cancellation_token.cancel();
} else {
self.dispatch_by_lidarr_block(&active_lidarr_block).await;
}
}
if self.tick_count.is_multiple_of(self.tick_until_poll) {
self.refresh_lidarr_metadata().await;
}
}
async fn refresh_lidarr_metadata(&mut self) {
self
.dispatch_network_event(LidarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetMetadataProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetRootFolders.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetDownloads(500).into())
.await;
self
.dispatch_network_event(LidarrEvent::GetDiskSpace.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetStatus.into())
.await;
}
}
+63 -1
View File
@@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken;
use veil::Redact; use veil::Redact;
use crate::cli::Command; use crate::cli::Command;
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::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::servarr_models::KeybindingItem; use crate::models::servarr_models::KeybindingItem;
@@ -25,6 +26,7 @@ mod app_tests;
pub mod context_clues; pub mod context_clues;
pub mod key_binding; pub mod key_binding;
mod key_binding_tests; mod key_binding_tests;
pub mod lidarr;
pub mod radarr; pub mod radarr;
pub mod sonarr; pub mod sonarr;
@@ -39,6 +41,7 @@ pub struct App<'a> {
pub tick_until_poll: u64, pub tick_until_poll: u64,
pub ticks_until_scroll: u64, pub ticks_until_scroll: u64,
pub tick_count: u64, pub tick_count: u64,
pub ui_scroll_tick_count: u64,
pub is_routing: bool, pub is_routing: bool,
pub is_loading: bool, pub is_loading: bool,
pub should_refresh: bool, pub should_refresh: bool,
@@ -95,6 +98,26 @@ impl App<'_> {
server_tabs.extend(sonarr_tabs); server_tabs.extend(sonarr_tabs);
} }
if let Some(lidarr_configs) = config.lidarr {
let mut unnamed_idx = 0;
let lidarr_tabs = lidarr_configs.into_iter().map(|lidarr_config| {
let name = if let Some(name) = lidarr_config.name.clone() {
name
} else {
unnamed_idx += 1;
format!("Lidarr {unnamed_idx}")
};
TabRoute {
title: name,
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(lidarr_config),
}
});
server_tabs.extend(lidarr_tabs);
}
let weight_sorted_tabs = server_tabs let weight_sorted_tabs = server_tabs
.into_iter() .into_iter()
.sorted_by(|tab1, tab2| { .sorted_by(|tab1, tab2| {
@@ -145,6 +168,14 @@ impl App<'_> {
self.tick_count = 0; self.tick_count = 0;
} }
pub fn on_ui_scroll_tick(&mut self) {
if self.ui_scroll_tick_count == self.ticks_until_scroll {
self.ui_scroll_tick_count = 0;
} else {
self.ui_scroll_tick_count += 1;
}
}
#[allow(dead_code)] #[allow(dead_code)]
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.reset_tick_count(); self.reset_tick_count();
@@ -167,6 +198,7 @@ impl App<'_> {
match self.get_current_route() { match self.get_current_route() {
Route::Radarr(active_radarr_block, _) => self.radarr_on_tick(active_radarr_block).await, 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::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,
_ => (), _ => (),
} }
@@ -227,6 +259,7 @@ impl Default for App<'_> {
tick_until_poll: 400, tick_until_poll: 400,
ticks_until_scroll: 4, ticks_until_scroll: 4,
tick_count: 0, tick_count: 0,
ui_scroll_tick_count: 0,
is_loading: false, is_loading: false,
is_routing: false, is_routing: false,
should_refresh: false, should_refresh: false,
@@ -254,6 +287,12 @@ impl App<'_> {
contextual_help: None, contextual_help: None,
config: Some(ServarrConfig::default()), config: Some(ServarrConfig::default()),
}, },
TabRoute {
title: "Lidarr".to_owned(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
]), ]),
..App::default() ..App::default()
} }
@@ -262,6 +301,7 @@ impl App<'_> {
pub fn test_default_fully_populated() -> Self { pub fn test_default_fully_populated() -> Self {
App { App {
data: Data { data: Data {
lidarr_data: LidarrData::test_default_fully_populated(),
radarr_data: RadarrData::test_default_fully_populated(), radarr_data: RadarrData::test_default_fully_populated(),
sonarr_data: SonarrData::test_default_fully_populated(), sonarr_data: SonarrData::test_default_fully_populated(),
}, },
@@ -278,6 +318,12 @@ impl App<'_> {
contextual_help: None, contextual_help: None,
config: Some(ServarrConfig::default()), config: Some(ServarrConfig::default()),
}, },
TabRoute {
title: "Lidarr".to_owned(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
]), ]),
..App::default() ..App::default()
} }
@@ -286,6 +332,7 @@ impl App<'_> {
#[derive(Default)] #[derive(Default)]
pub struct Data<'a> { pub struct Data<'a> {
pub lidarr_data: LidarrData<'a>,
pub radarr_data: RadarrData<'a>, pub radarr_data: RadarrData<'a>,
pub sonarr_data: SonarrData<'a>, pub sonarr_data: SonarrData<'a>,
} }
@@ -293,13 +340,14 @@ pub struct Data<'a> {
#[derive(Debug, Deserialize, Serialize, Default, Clone)] #[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct AppConfig { pub struct AppConfig {
pub theme: Option<String>, pub theme: Option<String>,
pub lidarr: Option<Vec<ServarrConfig>>,
pub radarr: Option<Vec<ServarrConfig>>, pub radarr: Option<Vec<ServarrConfig>>,
pub sonarr: Option<Vec<ServarrConfig>>, pub sonarr: Option<Vec<ServarrConfig>>,
} }
impl AppConfig { impl AppConfig {
pub fn validate(&self) { pub fn validate(&self) {
if self.radarr.is_none() && self.sonarr.is_none() { if self.lidarr.is_none() && self.radarr.is_none() && self.sonarr.is_none() {
log_and_print_error( log_and_print_error(
"No Servarr configuration provided in the specified configuration file".to_owned(), "No Servarr configuration provided in the specified configuration file".to_owned(),
); );
@@ -313,6 +361,10 @@ impl AppConfig {
if let Some(sonarr_configs) = &self.sonarr { if let Some(sonarr_configs) = &self.sonarr {
sonarr_configs.iter().for_each(|config| config.validate()); sonarr_configs.iter().for_each(|config| config.validate());
} }
if let Some(lidarr_configs) = &self.lidarr {
lidarr_configs.iter().for_each(|config| config.validate());
}
} }
pub fn verify_config_present_for_cli(&self, command: &Command) { pub fn verify_config_present_for_cli(&self, command: &Command) {
@@ -330,6 +382,10 @@ impl AppConfig {
msg("Sonarr"); msg("Sonarr");
process::exit(1); process::exit(1);
} }
Command::Lidarr(_) if self.lidarr.is_none() => {
msg("Lidarr");
process::exit(1);
}
_ => (), _ => (),
} }
} }
@@ -346,6 +402,12 @@ impl AppConfig {
sonarr_config.post_process_initialization(); sonarr_config.post_process_initialization();
} }
} }
if let Some(lidarr_configs) = self.lidarr.as_mut() {
for lidarr_config in lidarr_configs {
lidarr_config.post_process_initialization();
}
}
} }
} }
@@ -455,7 +455,6 @@ mod tests {
let mut app = App::test_default(); let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::default().into()); app.push_navigation_stack(ActiveRadarrBlock::default().into());
// This should panic because the route is not a Sonarr route
SonarrContextClueProvider::get_context_clues(&mut app); SonarrContextClueProvider::get_context_clues(&mut app);
} }
+9
View File
@@ -55,6 +55,13 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
#[test]
fn test_lidarr_subcommand_delegates_to_lidarr() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]);
assert_ok!(&result);
}
#[test] #[test]
fn test_completions_requires_argument() { fn test_completions_requires_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions"]); let result = Cli::command().try_get_matches_from(["managarr", "completions"]);
@@ -174,4 +181,6 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
// TODO: Implement test_cli_handler_delegates_lidarr_commands_to_the_lidarr_cli_handler
} }
+65
View File
@@ -0,0 +1,65 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{Subcommand, arg};
use tokio::sync::Mutex;
use super::LidarrCommand;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
#[cfg(test)]
#[path = "add_command_handler_tests.rs"]
mod add_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrAddCommand {
#[command(about = "Add new tag")]
Tag {
#[arg(long, help = "The name of the tag to be added", required = true)]
name: String,
},
}
impl From<LidarrAddCommand> for Command {
fn from(value: LidarrAddCommand) -> Self {
Command::Lidarr(LidarrCommand::Add(value))
}
}
pub(super) struct LidarrAddCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrAddCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrAddCommand> for LidarrAddCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrAddCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrAddCommandHandler {
_app: app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrAddCommand::Tag { name } => {
let resp = self
.network
.handle_network_event(LidarrEvent::AddTag(name).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+101
View File
@@ -0,0 +1,101 @@
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser, error::ErrorKind};
use crate::{
Cli,
cli::{
Command,
lidarr::{LidarrCommand, add_command_handler::LidarrAddCommand},
},
};
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_add_command_from() {
let command = LidarrAddCommand::Tag {
name: String::new(),
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::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", "lidarr", "add", "tag"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_tag_success() {
let expected_args = LidarrAddCommand::Tag {
name: "test".to_owned(),
};
let result = Cli::try_parse_from(["managarr", "lidarr", "add", "tag", "--name", "test"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::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::lidarr::add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler};
use crate::models::Serdeable;
use crate::models::lidarr_models::LidarrSerdeable;
use crate::network::lidarr_network::LidarrEvent;
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::<NetworkEvent>(
LidarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = LidarrAddCommand::Tag {
name: expected_tag_name,
};
let result = LidarrAddCommandHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
models::lidarr_models::DeleteArtistParams,
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "delete_command_handler_tests.rs"]
mod delete_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrDeleteCommand {
#[command(about = "Delete an artist from your Lidarr library")]
Artist {
#[arg(long, help = "The ID of the artist to delete", required = true)]
artist_id: i64,
#[arg(long, help = "Delete the artist files from disk as well")]
delete_files_from_disk: bool,
#[arg(long, help = "Add a list exclusion for this artist")]
add_list_exclusion: bool,
},
#[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<LidarrDeleteCommand> for Command {
fn from(value: LidarrDeleteCommand) -> Self {
Command::Lidarr(LidarrCommand::Delete(value))
}
}
pub(super) struct LidarrDeleteCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrDeleteCommand> for LidarrDeleteCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrDeleteCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrDeleteCommand::Artist {
artist_id,
delete_files_from_disk,
add_list_exclusion,
} => {
let delete_artist_params = DeleteArtistParams {
id: artist_id,
delete_files: delete_files_from_disk,
add_import_list_exclusion: add_list_exclusion,
};
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteArtist(delete_artist_params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::Tag { tag_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteTag(tag_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,196 @@
#[cfg(test)]
mod tests {
use crate::{
Cli,
cli::{
Command,
lidarr::{LidarrCommand, delete_command_handler::LidarrDeleteCommand},
},
};
use clap::{CommandFactory, Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_delete_command_from() {
let command = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: false,
add_list_exclusion: false,
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Delete(command)));
}
mod cli {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_delete_artist_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_artist_defaults() {
let expected_args = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: false,
add_list_exclusion: false,
};
let result =
Cli::try_parse_from(["managarr", "lidarr", "delete", "artist", "--artist-id", "1"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_artist_all_args_defined() {
let expected_args = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"artist",
"--artist-id",
"1",
"--delete-files-from-disk",
"--add-list-exclusion",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::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", "lidarr", "delete", "tag"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_tag_success() {
let expected_args = LidarrDeleteCommand::Tag { tag_id: 1 };
let result = Cli::try_parse_from(["managarr", "lidarr", "delete", "tag", "--tag-id", "1"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::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::{
app::App,
cli::{
CliCommandHandler,
lidarr::delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler},
},
models::{
Serdeable,
lidarr_models::{DeleteArtistParams, LidarrSerdeable},
},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_delete_artist_command() {
let expected_delete_artist_params = DeleteArtistParams {
id: 1,
delete_files: true,
add_import_list_exclusion: true,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteArtist(expected_delete_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_artist_command = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_tag_command() {
let expected_tag_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteTag(expected_tag_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_tag_command = LidarrDeleteCommand::Tag { tag_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{ArgAction, ArgGroup, Subcommand};
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command, mutex_flags_or_option},
models::lidarr_models::{EditArtistParams, NewItemMonitorType},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "edit_command_handler_tests.rs"]
mod edit_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrEditCommand {
#[command(
about = "Edit preferences for the specified artist",
group(
ArgGroup::new("edit_artist")
.args([
"enable_monitoring",
"disable_monitoring",
"monitor_new_items",
"quality_profile_id",
"metadata_profile_id",
"root_folder_path",
"tag",
"clear_tags"
]).required(true)
.multiple(true))
)]
Artist {
#[arg(
long,
help = "The ID of the artist whose settings you want to edit",
required = true
)]
artist_id: i64,
#[arg(
long,
help = "Enable monitoring of this artist in Lidarr so Lidarr will automatically download releases from this artist if they are available",
conflicts_with = "disable_monitoring"
)]
enable_monitoring: bool,
#[arg(
long,
help = "Disable monitoring of this artist so Lidarr does not automatically download releases from this artist if they are available",
conflicts_with = "enable_monitoring"
)]
disable_monitoring: bool,
#[arg(
long,
help = "How Lidarr should monitor new albums from this artist",
value_enum
)]
monitor_new_items: Option<NewItemMonitorType>,
#[arg(long, help = "The ID of the quality profile to use for this artist")]
quality_profile_id: Option<i64>,
#[arg(long, help = "The ID of the metadata profile to use for this artist")]
metadata_profile_id: Option<i64>,
#[arg(
long,
help = "The root folder path where all artist data and metadata should live"
)]
root_folder_path: Option<String>,
#[arg(
long,
help = "Tag IDs to tag this artist with",
value_parser,
action = ArgAction::Append,
conflicts_with = "clear_tags"
)]
tag: Option<Vec<i64>>,
#[arg(long, help = "Clear all tags on this artist", conflicts_with = "tag")]
clear_tags: bool,
},
}
impl From<LidarrEditCommand> for Command {
fn from(value: LidarrEditCommand) -> Self {
Command::Lidarr(LidarrCommand::Edit(value))
}
}
pub(super) struct LidarrEditCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrEditCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrEditCommand> for LidarrEditCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrEditCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrEditCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrEditCommand::Artist {
artist_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_artist_params = EditArtistParams {
artist_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(LidarrEvent::EditArtist(edit_artist_params).into())
.await?;
"Artist Updated".to_owned()
}
};
Ok(result)
}
}
@@ -0,0 +1,409 @@
#[cfg(test)]
mod tests {
use crate::cli::{
Command,
lidarr::{LidarrCommand, edit_command_handler::LidarrEditCommand},
};
#[test]
fn test_lidarr_edit_command_from() {
let command = LidarrEditCommand::Artist {
artist_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::Lidarr(LidarrCommand::Edit(command)));
}
mod cli {
use crate::{Cli, models::lidarr_models::NewItemMonitorType};
use super::*;
use clap::{CommandFactory, Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
use rstest::rstest;
#[test]
fn test_edit_artist_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "edit", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_artist_with_artist_id_still_requires_arguments() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_artist_monitoring_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--enable-monitoring",
"--disable-monitoring",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn test_edit_artist_tag_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--tag",
"1",
"--clear-tags",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[rstest]
fn test_edit_artist_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",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
flag,
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_artist_monitor_new_items_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--monitor-new-items",
"test",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_artist_only_requires_at_least_one_argument_plus_artist_id() {
let expected_args = LidarrEditCommand::Artist {
artist_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/test".to_owned()),
tag: None,
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--root-folder-path",
"/nfs/test",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_artist_tag_argument_is_repeatable() {
let expected_args = LidarrEditCommand::Artist {
artist_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",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_artist_all_arguments_defined() {
let expected_args = LidarrEditCommand::Artist {
artist_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/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--enable-monitoring",
"--monitor-new-items",
"new",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
"--root-folder-path",
"/nfs/test",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::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::{
app::App,
cli::{
CliCommandHandler,
lidarr::edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler},
},
models::{
Serdeable,
lidarr_models::{EditArtistParams, LidarrSerdeable, NewItemMonitorType},
},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_edit_artist_command() {
let expected_edit_artist_params = EditArtistParams {
artist_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/test".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::<NetworkEvent>(
LidarrEvent::EditArtist(expected_edit_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_artist_command = LidarrEditCommand::Artist {
artist_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/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_artist_command_handles_disable_monitoring_flag_properly() {
let expected_edit_artist_params = EditArtistParams {
artist_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/test".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::<NetworkEvent>(
LidarrEvent::EditArtist(expected_edit_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_artist_command = LidarrEditCommand::Artist {
artist_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/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_artist_command_no_monitoring_boolean_flags_returns_none_value() {
let expected_edit_artist_params = EditArtistParams {
artist_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/test".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::<NetworkEvent>(
LidarrEvent::EditArtist(expected_edit_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_artist_command = LidarrEditCommand::Artist {
artist_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/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+97
View File
@@ -0,0 +1,97 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "get_command_handler_tests.rs"]
mod get_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrGetCommand {
#[command(about = "Get detailed information for the artist with the given ID")]
ArtistDetails {
#[arg(
long,
help = "The Lidarr ID of the artist whose details you wish to fetch",
required = true
)]
artist_id: i64,
},
#[command(about = "Fetch the host config for your Lidarr instance")]
HostConfig,
#[command(about = "Fetch the security config for your Lidarr instance")]
SecurityConfig,
#[command(about = "Get the system status")]
SystemStatus,
}
impl From<LidarrGetCommand> for Command {
fn from(value: LidarrGetCommand) -> Self {
Command::Lidarr(LidarrCommand::Get(value))
}
}
pub(super) struct LidarrGetCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrGetCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrGetCommand> for LidarrGetCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrGetCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrGetCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrGetCommand::ArtistDetails { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetArtistDetails(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::HostConfig => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetHostConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::SecurityConfig => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetSecurityConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::SystemStatus => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetStatus.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+189
View File
@@ -0,0 +1,189 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, get_command_handler::LidarrGetCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_get_command_from() {
let command = LidarrGetCommand::SystemStatus;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Get(command)));
}
mod cli {
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_artist_details_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "artist-details"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_artist_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"get",
"artist-details",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_host_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "host-config"]);
assert_ok!(&result);
}
#[test]
fn test_security_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "security-config"]);
assert_ok!(&result);
}
#[test]
fn test_system_status_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "system-status"]);
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,
lidarr::get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler},
},
models::{Serdeable, lidarr_models::LidarrSerdeable},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_get_artist_details_command() {
let expected_artist_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetArtistDetails(expected_artist_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_artist_details_command = LidarrGetCommand::ArtistDetails { artist_id: 1 };
let result =
LidarrGetCommandHandler::with(&app_arc, get_artist_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();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetHostConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_host_config_command = LidarrGetCommand::HostConfig;
let result =
LidarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network)
.handle()
.await;
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::<NetworkEvent>(LidarrEvent::GetSecurityConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_security_config_command = LidarrGetCommand::SecurityConfig;
let result =
LidarrGetCommandHandler::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();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = LidarrGetCommand::SystemStatus;
let result =
LidarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+310
View File
@@ -0,0 +1,310 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, list_command_handler::LidarrListCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_command_from() {
let command = LidarrCommand::List(LidarrListCommand::Artists);
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(command));
}
mod cli {
use super::*;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
#[test]
fn test_list_artists_has_no_arg_requirements() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]);
assert_ok!(&result);
}
#[test]
fn test_lidarr_list_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list"]);
assert_err!(&result);
}
#[test]
fn test_lidarr_add_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "add"]);
assert_err!(&result);
}
#[test]
fn test_lidarr_delete_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete"]);
assert_err!(&result);
}
#[test]
fn test_toggle_artist_monitoring_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "toggle-artist-monitoring"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_toggle_artist_monitoring_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"toggle-artist-monitoring",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_search_new_artist_requires_query() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "search-new-artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_search_new_artist_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"search-new-artist",
"--query",
"test query",
]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::lidarr::add_command_handler::LidarrAddCommand;
use crate::cli::lidarr::get_command_handler::LidarrGetCommand;
use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand;
use crate::{
app::App,
cli::{
CliCommandHandler,
lidarr::{
LidarrCliHandler, LidarrCommand, delete_command_handler::LidarrDeleteCommand,
list_command_handler::LidarrListCommand,
},
},
models::{
Serdeable,
lidarr_models::{Artist, DeleteArtistParams, LidarrSerdeable},
},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_add_commands_to_the_add_command_handler() {
let expected_tag_name = "test".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = LidarrCommand::Add(LidarrAddCommand::Tag {
name: expected_tag_name,
});
let result = LidarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_get_commands_to_the_get_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = LidarrCommand::Get(LidarrGetCommand::SystemStatus);
let result = LidarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() {
let expected_delete_artist_params = DeleteArtistParams {
id: 1,
delete_files: true,
add_import_list_exclusion: true,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteArtist(expected_delete_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_artist_command = LidarrCommand::Delete(LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
});
let result = LidarrCliHandler::with(&app_arc, delete_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_list_commands_to_the_list_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::ListArtists.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Artists(vec![
Artist::default(),
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_artists_command = LidarrCommand::List(LidarrListCommand::Artists);
let result = LidarrCliHandler::with(&app_arc, list_artists_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::UpdateAllArtists.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_series_command = LidarrCommand::Refresh(LidarrRefreshCommand::AllArtists);
let result = LidarrCliHandler::with(&app_arc, refresh_series_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_toggle_artist_monitoring_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::ToggleArtistMonitoring(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let toggle_artist_monitoring_command = LidarrCommand::ToggleArtistMonitoring { artist_id: 1 };
let result = LidarrCliHandler::with(
&app_arc,
toggle_artist_monitoring_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_search_new_artist_command() {
let expected_query = "test artist".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::SearchNewArtist(expected_query.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let search_new_artist_command = LidarrCommand::SearchNewArtist {
query: expected_query,
};
let result = LidarrCliHandler::with(&app_arc, search_new_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+90
View File
@@ -0,0 +1,90 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "list_command_handler_tests.rs"]
mod list_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrListCommand {
#[command(about = "List all artists in your Lidarr library")]
Artists,
#[command(about = "List all Lidarr metadata profiles")]
MetadataProfiles,
#[command(about = "List all Lidarr quality profiles")]
QualityProfiles,
#[command(about = "List all Lidarr tags")]
Tags,
}
impl From<LidarrListCommand> for Command {
fn from(value: LidarrListCommand) -> Self {
Command::Lidarr(LidarrCommand::List(value))
}
}
pub(super) struct LidarrListCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrListCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrListCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrListCommandHandler {
_app: app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrListCommand::Artists => {
let resp = self
.network
.handle_network_event(LidarrEvent::ListArtists.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::MetadataProfiles => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetMetadataProfiles.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::QualityProfiles => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetQualityProfiles.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Tags => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTags.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,81 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, list_command_handler::LidarrListCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_list_command_from() {
let command = LidarrListCommand::Artists;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::List(command)));
}
mod cli {
use super::*;
use rstest::rstest;
#[rstest]
fn test_list_commands_have_no_arg_requirements(
#[values("artists", "metadata-profiles", "quality-profiles", "tags")] subcommand: &str,
) {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", subcommand]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use rstest::rstest;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::CliCommandHandler;
use crate::cli::lidarr::list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use crate::models::Serdeable;
use crate::models::lidarr_models::LidarrSerdeable;
use crate::network::lidarr_network::LidarrEvent;
use crate::{
app::App,
network::{MockNetworkTrait, NetworkEvent},
};
#[rstest]
#[case(LidarrListCommand::Artists, LidarrEvent::ListArtists)]
#[case(LidarrListCommand::MetadataProfiles, LidarrEvent::GetMetadataProfiles)]
#[case(LidarrListCommand::QualityProfiles, LidarrEvent::GetQualityProfiles)]
#[case(LidarrListCommand::Tags, LidarrEvent::GetTags)]
#[tokio::test]
async fn test_handle_list_command(
#[case] list_command: LidarrListCommand,
#[case] expected_lidarr_event: LidarrEvent,
) {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(expected_lidarr_event.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let result = LidarrListCommandHandler::with(&app_arc, list_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+158
View File
@@ -0,0 +1,158 @@
use std::sync::Arc;
use add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler};
use anyhow::Result;
use clap::{Subcommand, arg};
use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler};
use edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler};
use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler};
use list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler};
use tokio::sync::Mutex;
use crate::network::lidarr_network::LidarrEvent;
use crate::{app::App, network::NetworkTrait};
use super::{CliCommandHandler, Command};
mod add_command_handler;
mod delete_command_handler;
mod edit_command_handler;
mod get_command_handler;
mod list_command_handler;
mod refresh_command_handler;
#[cfg(test)]
#[path = "lidarr_command_tests.rs"]
mod lidarr_command_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrCommand {
#[command(
subcommand,
about = "Commands to add or create new resources within your Lidarr instance"
)]
Add(LidarrAddCommand),
#[command(
subcommand,
about = "Commands to delete resources from your Lidarr instance"
)]
Delete(LidarrDeleteCommand),
#[command(
subcommand,
about = "Commands to edit resources in your Lidarr instance"
)]
Edit(LidarrEditCommand),
#[command(
subcommand,
about = "Commands to fetch details of the resources in your Lidarr instance"
)]
Get(LidarrGetCommand),
#[command(
subcommand,
about = "Commands to list attributes from your Lidarr instance"
)]
List(LidarrListCommand),
#[command(
subcommand,
about = "Commands to refresh the data in your Lidarr instance"
)]
Refresh(LidarrRefreshCommand),
#[command(about = "Search for a new artist to add to Lidarr")]
SearchNewArtist {
#[arg(
long,
help = "The name of the artist you want to search for",
required = true
)]
query: String,
},
#[command(
about = "Toggle monitoring for the specified artist corresponding to the given artist ID"
)]
ToggleArtistMonitoring {
#[arg(
long,
help = "The Lidarr ID of the artist to toggle monitoring on",
required = true
)]
artist_id: i64,
},
}
impl From<LidarrCommand> for Command {
fn from(lidarr_command: LidarrCommand) -> Command {
Command::Lidarr(lidarr_command)
}
}
pub(super) struct LidarrCliHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrCliHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrCommand::Add(add_command) => {
LidarrAddCommandHandler::with(self.app, add_command, self.network)
.handle()
.await?
}
LidarrCommand::Delete(delete_command) => {
LidarrDeleteCommandHandler::with(self.app, delete_command, self.network)
.handle()
.await?
}
LidarrCommand::Edit(edit_command) => {
LidarrEditCommandHandler::with(self.app, edit_command, self.network)
.handle()
.await?
}
LidarrCommand::Get(get_command) => {
LidarrGetCommandHandler::with(self.app, get_command, self.network)
.handle()
.await?
}
LidarrCommand::List(list_command) => {
LidarrListCommandHandler::with(self.app, list_command, self.network)
.handle()
.await?
}
LidarrCommand::Refresh(refresh_command) => {
LidarrRefreshCommandHandler::with(self.app, refresh_command, self.network)
.handle()
.await?
}
LidarrCommand::SearchNewArtist { query } => {
let resp = self
.network
.handle_network_event(LidarrEvent::SearchNewArtist(query).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::ToggleArtistMonitoring { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::ToggleArtistMonitoring(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+64
View File
@@ -0,0 +1,64 @@
use std::sync::Arc;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "refresh_command_handler_tests.rs"]
mod refresh_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrRefreshCommand {
#[command(about = "Refresh all artist data for all artists in your Lidarr library")]
AllArtists,
}
impl From<LidarrRefreshCommand> for Command {
fn from(value: LidarrRefreshCommand) -> Self {
Command::Lidarr(LidarrCommand::Refresh(value))
}
}
pub(super) struct LidarrRefreshCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrRefreshCommand>
for LidarrRefreshCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrRefreshCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> anyhow::Result<String> {
let result = match self.command {
LidarrRefreshCommand::AllArtists => {
let resp = self
.network
.handle_network_event(LidarrEvent::UpdateAllArtists.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,72 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, refresh_command_handler::LidarrRefreshCommand},
};
use clap::CommandFactory;
#[test]
fn test_lidarr_refresh_command_from() {
let command = LidarrRefreshCommand::AllArtists;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Refresh(command)));
}
mod cli {
use super::*;
#[test]
fn test_refresh_all_artists_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "refresh", "all-artists"]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{app::App, cli::lidarr::refresh_command_handler::LidarrRefreshCommandHandler};
use crate::{
cli::{CliCommandHandler, lidarr::refresh_command_handler::LidarrRefreshCommand},
network::lidarr_network::LidarrEvent,
};
use crate::{
models::{Serdeable, lidarr_models::LidarrSerdeable},
network::{MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_handle_refresh_all_artists_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::UpdateAllArtists.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_command = LidarrRefreshCommand::AllArtists;
let result = LidarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+10
View File
@@ -3,12 +3,14 @@ use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{Subcommand, command}; use clap::{Subcommand, command};
use clap_complete::Shell; use clap_complete::Shell;
use lidarr::{LidarrCliHandler, LidarrCommand};
use radarr::{RadarrCliHandler, RadarrCommand}; use radarr::{RadarrCliHandler, RadarrCommand};
use sonarr::{SonarrCliHandler, SonarrCommand}; use sonarr::{SonarrCliHandler, SonarrCommand};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{app::App, network::NetworkTrait}; use crate::{app::App, network::NetworkTrait};
pub mod lidarr;
pub mod radarr; pub mod radarr;
pub mod sonarr; pub mod sonarr;
@@ -24,6 +26,9 @@ pub enum Command {
#[command(subcommand, about = "Commands for manging your Sonarr instance")] #[command(subcommand, about = "Commands for manging your Sonarr instance")]
Sonarr(SonarrCommand), Sonarr(SonarrCommand),
#[command(subcommand, about = "Commands for manging your Lidarr instance")]
Lidarr(LidarrCommand),
#[command( #[command(
arg_required_else_help = true, arg_required_else_help = true,
about = "Generate shell completions for the Managarr CLI" about = "Generate shell completions for the Managarr CLI"
@@ -61,6 +66,11 @@ pub(crate) async fn handle_command(
.handle() .handle()
.await? .await?
} }
Command::Lidarr(lidarr_command) => {
LidarrCliHandler::with(app, lidarr_command, network)
.handle()
.await?
}
_ => String::new(), _ => String::new(),
}; };
+6 -2
View File
@@ -1,3 +1,4 @@
use anyhow::Result;
use std::sync::mpsc; use std::sync::mpsc;
use std::sync::mpsc::Receiver; use std::sync::mpsc::Receiver;
use std::thread; use std::thread;
@@ -49,7 +50,10 @@ impl Events {
Events { rx } Events { rx }
} }
pub fn next(&self) -> Result<InputEvent<Key>, mpsc::RecvError> { pub fn next(&self) -> Result<Option<InputEvent<Key>>> {
self.rx.recv() match self.rx.try_recv() {
Ok(event) => Ok(Some(event)),
_ => Ok(None),
}
} }
} }
+2 -31
View File
@@ -4,13 +4,12 @@ mod property_tests {
use crate::app::App; use crate::app::App;
use crate::handlers::handler_test_utils::test_utils::proptest_helpers::*; use crate::handlers::handler_test_utils::test_utils::proptest_helpers::*;
use crate::models::radarr_models::Movie;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::stateful_table::StatefulTable; use crate::models::stateful_table::StatefulTable;
use crate::models::radarr_models::Movie; use crate::models::{Paginated, Scrollable};
use crate::models::{Scrollable, Paginated};
proptest! { proptest! {
/// Property test: Table never panics on index selection
#[test] #[test]
fn test_table_index_selection_safety( fn test_table_index_selection_safety(
list_size in list_size(), list_size in list_size(),
@@ -25,19 +24,15 @@ mod property_tests {
table.set_items(movies); table.set_items(movies);
// Try to select an arbitrary index
if index < list_size { if index < list_size {
table.select_index(Some(index)); table.select_index(Some(index));
let selected = table.current_selection(); let selected = table.current_selection();
prop_assert_eq!(selected.id, index as i64); prop_assert_eq!(selected.id, index as i64);
} else { } else {
// Out of bounds selection should be safe
table.select_index(Some(index)); table.select_index(Some(index));
// Should not panic, selection stays valid
} }
} }
/// Property test: Table state remains consistent after scroll operations
#[test] #[test]
fn test_table_scroll_consistency( fn test_table_scroll_consistency(
list_size in list_size(), list_size in list_size(),
@@ -53,42 +48,34 @@ mod property_tests {
table.set_items(movies); table.set_items(movies);
let initial_id = table.current_selection().id; let initial_id = table.current_selection().id;
// Scroll down multiple times
for _ in 0..scroll_amount { for _ in 0..scroll_amount {
table.scroll_down(); table.scroll_down();
} }
let after_down_id = table.current_selection().id; let after_down_id = table.current_selection().id;
// Position should increase (up to max)
prop_assert!(after_down_id >= initial_id); prop_assert!(after_down_id >= initial_id);
prop_assert!(after_down_id < list_size as i64); prop_assert!(after_down_id < list_size as i64);
// Scroll back up
for _ in 0..scroll_amount { for _ in 0..scroll_amount {
table.scroll_up(); table.scroll_up();
} }
// Should return to initial position (or 0 if we hit the top)
prop_assert!(table.current_selection().id <= initial_id); prop_assert!(table.current_selection().id <= initial_id);
} }
/// Property test: Empty tables handle operations gracefully
#[test] #[test]
fn test_empty_table_safety(_scroll_ops in 0usize..50) { fn test_empty_table_safety(_scroll_ops in 0usize..50) {
let table = StatefulTable::<Movie>::default(); let table = StatefulTable::<Movie>::default();
// Empty table operations should be safe
prop_assert!(table.is_empty()); prop_assert!(table.is_empty());
prop_assert!(table.items.is_empty()); prop_assert!(table.items.is_empty());
} }
/// Property test: Navigation operations maintain consistency
#[test] #[test]
fn test_navigation_consistency(pushes in 1usize..20) { fn test_navigation_consistency(pushes in 1usize..20) {
let mut app = App::test_default(); let mut app = App::test_default();
let initial_route = app.get_current_route(); let initial_route = app.get_current_route();
// Push multiple routes
let routes = vec![ let routes = vec![
ActiveRadarrBlock::Movies, ActiveRadarrBlock::Movies,
ActiveRadarrBlock::Collections, ActiveRadarrBlock::Collections,
@@ -101,34 +88,27 @@ mod property_tests {
app.push_navigation_stack(route.into()); app.push_navigation_stack(route.into());
} }
// Current route should be the last pushed
let last_pushed = routes[(pushes - 1) % routes.len()]; let last_pushed = routes[(pushes - 1) % routes.len()];
prop_assert_eq!(app.get_current_route(), last_pushed.into()); prop_assert_eq!(app.get_current_route(), last_pushed.into());
// Pop all routes
for _ in 0..pushes { for _ in 0..pushes {
app.pop_navigation_stack(); app.pop_navigation_stack();
} }
// Should return to initial route
prop_assert_eq!(app.get_current_route(), initial_route); prop_assert_eq!(app.get_current_route(), initial_route);
} }
/// Property test: String input handling is safe
#[test] #[test]
fn test_string_input_safety(input in text_input_string()) { fn test_string_input_safety(input in text_input_string()) {
// String operations should never panic
let _lowercase = input.to_lowercase(); let _lowercase = input.to_lowercase();
let _uppercase = input.to_uppercase(); let _uppercase = input.to_uppercase();
let _trimmed = input.trim(); let _trimmed = input.trim();
let _len = input.len(); let _len = input.len();
let _chars: Vec<char> = input.chars().collect(); let _chars: Vec<char> = input.chars().collect();
// All operations completed without panic
prop_assert!(true); prop_assert!(true);
} }
/// Property test: Table maintains data integrity after operations
#[test] #[test]
fn test_table_data_integrity( fn test_table_data_integrity(
list_size in 1usize..100 list_size in 1usize..100
@@ -144,16 +124,13 @@ mod property_tests {
table.set_items(movies.clone()); table.set_items(movies.clone());
let original_count = table.items.len(); let original_count = table.items.len();
// Count should remain the same after various operations
prop_assert_eq!(table.items.len(), original_count); prop_assert_eq!(table.items.len(), original_count);
// All original items should still be present
for movie in &movies { for movie in &movies {
prop_assert!(table.items.iter().any(|m| m.id == movie.id)); prop_assert!(table.items.iter().any(|m| m.id == movie.id));
} }
} }
/// Property test: Page up/down maintains bounds
#[test] #[test]
fn test_page_navigation_bounds( fn test_page_navigation_bounds(
list_size in list_size(), list_size in list_size(),
@@ -168,7 +145,6 @@ mod property_tests {
table.set_items(movies); table.set_items(movies);
// Perform page operations
for i in 0..page_ops { for i in 0..page_ops {
if i % 2 == 0 { if i % 2 == 0 {
table.page_down(); table.page_down();
@@ -176,14 +152,12 @@ mod property_tests {
table.page_up(); table.page_up();
} }
// Should never exceed bounds
let current = table.current_selection(); let current = table.current_selection();
prop_assert!(current.id >= 0); prop_assert!(current.id >= 0);
prop_assert!(current.id < list_size as i64); prop_assert!(current.id < list_size as i64);
} }
} }
/// Property test: Table filtering reduces or maintains size
#[test] #[test]
fn test_table_filter_size_invariant( fn test_table_filter_size_invariant(
list_size in list_size(), list_size in list_size(),
@@ -200,7 +174,6 @@ mod property_tests {
table.set_items(movies.clone()); table.set_items(movies.clone());
let original_size = table.items.len(); let original_size = table.items.len();
// Apply filter
if !filter_term.is_empty() { if !filter_term.is_empty() {
let filtered: Vec<Movie> = movies.into_iter() let filtered: Vec<Movie> = movies.into_iter()
.filter(|m| m.title.text.to_lowercase().contains(&filter_term.to_lowercase())) .filter(|m| m.title.text.to_lowercase().contains(&filter_term.to_lowercase()))
@@ -208,10 +181,8 @@ mod property_tests {
table.set_items(filtered); table.set_items(filtered);
} }
// Filtered size should be <= original
prop_assert!(table.items.len() <= original_size); prop_assert!(table.items.len() <= original_size);
// Selection should still be valid if table not empty
if !table.items.is_empty() { if !table.items.is_empty() {
let current = table.current_selection(); let current = table.current_selection();
prop_assert!(current.id >= 0); prop_assert!(current.id >= 0);
+10 -4
View File
@@ -21,6 +21,7 @@ mod tests {
use crate::models::HorizontallyScrollableText; use crate::models::HorizontallyScrollableText;
use crate::models::Route; use crate::models::Route;
use crate::models::servarr_data::ActiveKeybindingBlock; use crate::models::servarr_data::ActiveKeybindingBlock;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::servarr_models::KeybindingItem; use crate::models::servarr_models::KeybindingItem;
@@ -60,11 +61,16 @@ mod tests {
} }
#[rstest] #[rstest]
#[case(0, ActiveSonarrBlock::Series, ActiveSonarrBlock::Series)] #[case(0, ActiveLidarrBlock::Artists, ActiveSonarrBlock::Series)]
#[case(1, ActiveRadarrBlock::Movies, ActiveRadarrBlock::Movies)] #[case(1, ActiveRadarrBlock::Movies, ActiveLidarrBlock::Artists)]
fn test_handle_change_tabs<T>(#[case] index: usize, #[case] left_block: T, #[case] right_block: T) #[case(2, ActiveSonarrBlock::Series, ActiveRadarrBlock::Movies)]
where fn test_handle_change_tabs<T, U>(
#[case] index: usize,
#[case] left_block: T,
#[case] right_block: U,
) where
T: Into<Route> + Copy, T: Into<Route> + Copy,
U: Into<Route> + Copy,
{ {
let mut app = App::test_default(); let mut app = App::test_default();
app.error = "Test".into(); app.error = "Test".into();
+2 -1
View File
@@ -2,6 +2,7 @@ use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::KeyEventHandler; use crate::handlers::KeyEventHandler;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::models::Route;
use crate::models::servarr_data::ActiveKeybindingBlock; use crate::models::servarr_data::ActiveKeybindingBlock;
#[cfg(test)] #[cfg(test)]
@@ -75,7 +76,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveKeybindingBlock> for KeybindingHandle
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -0,0 +1,181 @@
use crate::handlers::KeyEventHandler;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::models::Route;
use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock};
use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys};
#[cfg(test)]
#[path = "add_artist_handler_tests.rs"]
mod add_artist_handler_tests;
pub struct AddArtistHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_lidarr_block: ActiveLidarrBlock,
_context: Option<ActiveLidarrBlock>,
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AddArtistHandler<'a, 'b> {
fn handle(&mut self) {
let add_artist_table_handling_config =
TableHandlingConfig::new(ActiveLidarrBlock::AddArtistSearchResults.into());
if !handle_table(
self,
|app| {
app
.data
.lidarr_data
.add_searched_artists
.as_mut()
.expect("add_searched_artists should be initialized")
},
add_artist_table_handling_config,
) {
self.handle_key_event();
}
}
fn accepts(active_block: ActiveLidarrBlock) -> bool {
ADD_ARTIST_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<ActiveLidarrBlock>,
) -> AddArtistHandler<'a, 'b> {
AddArtistHandler {
key,
app,
active_lidarr_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) {}
fn handle_scroll_down(&mut self) {}
fn handle_home(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput {
self
.app
.data
.lidarr_data
.add_artist_search
.as_mut()
.unwrap()
.scroll_home();
}
}
fn handle_end(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput {
self
.app
.data
.lidarr_data
.add_artist_search
.as_mut()
.unwrap()
.reset_offset();
}
}
fn handle_delete(&mut self) {}
fn handle_left_right_action(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput {
handle_text_box_left_right_keys!(
self,
self.key,
self
.app
.data
.lidarr_data
.add_artist_search
.as_mut()
.unwrap()
)
}
}
fn handle_submit(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::AddArtistSearchInput => {
let search_text = &self
.app
.data
.lidarr_data
.add_artist_search
.as_ref()
.unwrap()
.text;
if !search_text.is_empty() {
self
.app
.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into());
self.app.ignore_special_keys_for_textbox_input = false;
}
}
ActiveLidarrBlock::AddArtistSearchResults => {}
_ => (),
}
}
fn handle_esc(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::AddArtistSearchInput => {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.add_artist_search = None;
self.app.ignore_special_keys_for_textbox_input = false;
}
ActiveLidarrBlock::AddArtistSearchResults
| ActiveLidarrBlock::AddArtistEmptySearchResults => {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.add_searched_artists = None;
self.app.ignore_special_keys_for_textbox_input = true;
}
_ => (),
}
}
fn handle_char_key_event(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput {
handle_text_box_keys!(
self,
self.key,
self
.app
.data
.lidarr_data
.add_artist_search
.as_mut()
.unwrap()
)
}
}
fn app_mut(&mut self) -> &mut App<'b> {
self.app
}
fn current_route(&self) -> Route {
self.app.get_current_route()
}
}
@@ -0,0 +1,356 @@
#[cfg(test)]
mod tests {
use pretty_assertions::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_navigation_popped;
use crate::event::Key;
use crate::handlers::KeyEventHandler;
use crate::handlers::lidarr_handlers::library::add_artist_handler::AddArtistHandler;
use crate::models::HorizontallyScrollableText;
use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock};
use crate::models::stateful_table::StatefulTable;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::add_artist_search_result;
mod test_handle_home_end {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_add_artist_search_input_home_end_keys() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into());
app.data.lidarr_data.add_artist_search = Some("Test".into());
AddArtistHandler::new(
DEFAULT_KEYBINDINGS.home.key,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
)
.handle();
assert_eq!(
app
.data
.lidarr_data
.add_artist_search
.as_ref()
.unwrap()
.offset
.load(Ordering::SeqCst),
4
);
AddArtistHandler::new(
DEFAULT_KEYBINDINGS.end.key,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
)
.handle();
assert_eq!(
app
.data
.lidarr_data
.add_artist_search
.as_ref()
.unwrap()
.offset
.load(Ordering::SeqCst),
0
);
}
}
mod test_handle_left_right_action {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_add_artist_search_input_left_right_keys() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into());
app.data.lidarr_data.add_artist_search = Some("Test".into());
AddArtistHandler::new(
DEFAULT_KEYBINDINGS.left.key,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
)
.handle();
assert_eq!(
app
.data
.lidarr_data
.add_artist_search
.as_ref()
.unwrap()
.offset
.load(Ordering::SeqCst),
1
);
AddArtistHandler::new(
DEFAULT_KEYBINDINGS.right.key,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
)
.handle();
assert_eq!(
app
.data
.lidarr_data
.add_artist_search
.as_ref()
.unwrap()
.offset
.load(Ordering::SeqCst),
0
);
}
}
mod test_handle_submit {
use super::*;
use pretty_assertions::assert_eq;
const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key;
#[test]
fn test_add_artist_search_input_submit() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into());
app.ignore_special_keys_for_textbox_input = true;
app.data.lidarr_data.add_artist_search = Some("test".into());
AddArtistHandler::new(
SUBMIT_KEY,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
)
.handle();
assert!(!app.ignore_special_keys_for_textbox_input);
assert_eq!(
app.get_current_route(),
ActiveLidarrBlock::AddArtistSearchResults.into()
);
}
#[test]
fn test_add_artist_search_input_submit_noop_on_empty_search() {
let mut app = App::test_default();
app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default());
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into());
app.ignore_special_keys_for_textbox_input = true;
AddArtistHandler::new(
SUBMIT_KEY,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
)
.handle();
assert!(app.ignore_special_keys_for_textbox_input);
assert_eq!(
app.get_current_route(),
ActiveLidarrBlock::AddArtistSearchInput.into()
);
}
}
mod test_handle_esc {
use super::*;
use crate::assert_modal_absent;
use rstest::rstest;
const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key;
#[rstest]
fn test_add_artist_search_input_esc(#[values(true, false)] is_ready: bool) {
let mut app = App::test_default();
app.is_loading = is_ready;
app.data.lidarr_data.add_artist_search = Some("test".into());
app.ignore_special_keys_for_textbox_input = true;
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into());
AddArtistHandler::new(
ESC_KEY,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
)
.handle();
assert!(!app.ignore_special_keys_for_textbox_input);
assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into());
assert_modal_absent!(app.data.lidarr_data.add_artist_search);
}
#[rstest]
fn test_add_artist_search_results_esc(
#[values(
ActiveLidarrBlock::AddArtistSearchResults,
ActiveLidarrBlock::AddArtistEmptySearchResults
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into());
app.push_navigation_stack(active_lidarr_block.into());
let mut add_searched_artists = StatefulTable::default();
add_searched_artists.set_items(vec![add_artist_search_result()]);
app.data.lidarr_data.add_searched_artists = Some(add_searched_artists);
AddArtistHandler::new(ESC_KEY, &mut app, active_lidarr_block, None).handle();
assert_navigation_popped!(app, ActiveLidarrBlock::AddArtistSearchInput.into());
assert_modal_absent!(app.data.lidarr_data.add_searched_artists);
assert!(app.ignore_special_keys_for_textbox_input);
}
}
mod test_handle_key_char {
use super::*;
#[test]
fn test_add_artist_search_input_backspace() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.data.lidarr_data.add_artist_search = Some("Test".into());
AddArtistHandler::new(
DEFAULT_KEYBINDINGS.backspace.key,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
)
.handle();
assert_str_eq!(
app
.data
.lidarr_data
.add_artist_search
.as_ref()
.unwrap()
.text,
"Tes"
);
}
#[test]
fn test_add_artist_search_input_char_key() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default());
AddArtistHandler::new(
Key::Char('a'),
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
)
.handle();
assert_str_eq!(
app
.data
.lidarr_data
.add_artist_search
.as_ref()
.unwrap()
.text,
"a"
);
}
}
#[test]
fn test_add_artist_handler_accepts() {
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) {
assert!(AddArtistHandler::accepts(active_lidarr_block));
} else {
assert!(!AddArtistHandler::accepts(active_lidarr_block));
}
});
}
#[rstest]
fn test_add_artist_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 = AddArtistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::default(),
None,
);
assert_eq!(
handler.ignore_special_keys(),
ignore_special_keys_for_textbox_input
);
}
#[test]
fn test_add_artist_search_no_panic_on_none_search_result() {
let mut app = App::test_default();
app.data.lidarr_data.add_searched_artists = None;
AddArtistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::AddArtistSearchResults,
None,
)
.handle();
}
#[test]
fn test_add_artist_handler_is_not_ready_when_loading() {
let mut app = App::test_default();
app.is_loading = true;
let handler = AddArtistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_add_artist_handler_is_ready_when_not_loading() {
let mut app = App::test_default();
app.is_loading = false;
let handler = AddArtistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::AddArtistSearchInput,
None,
);
assert!(handler.is_ready());
}
}
@@ -0,0 +1,150 @@
use crate::models::Route;
use crate::models::lidarr_models::DeleteArtistParams;
use crate::network::lidarr_network::LidarrEvent;
use crate::{
app::App,
event::Key,
handlers::{KeyEventHandler, handle_prompt_toggle},
matches_key,
models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_BLOCKS},
};
#[cfg(test)]
#[path = "delete_artist_handler_tests.rs"]
mod delete_artist_handler_tests;
pub(in crate::handlers::lidarr_handlers) struct DeleteArtistHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_lidarr_block: ActiveLidarrBlock,
_context: Option<ActiveLidarrBlock>,
}
impl DeleteArtistHandler<'_, '_> {
fn build_delete_artist_params(&mut self) -> DeleteArtistParams {
let id = self.app.data.lidarr_data.artists.current_selection().id;
let delete_files = self.app.data.lidarr_data.delete_artist_files;
let add_import_list_exclusion = self.app.data.lidarr_data.add_import_list_exclusion;
self.app.data.lidarr_data.reset_delete_artist_preferences();
DeleteArtistParams {
id,
delete_files,
add_import_list_exclusion,
}
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for DeleteArtistHandler<'a, 'b> {
fn accepts(active_block: ActiveLidarrBlock) -> bool {
DELETE_ARTIST_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<ActiveLidarrBlock>,
) -> Self {
DeleteArtistHandler {
key,
app,
active_lidarr_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_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt {
self.app.data.lidarr_data.selected_block.up();
}
}
fn handle_scroll_down(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt {
self.app.data.lidarr_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_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt {
handle_prompt_toggle(self.app, self.key);
}
}
fn handle_submit(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt {
match self.app.data.lidarr_data.selected_block.get_active_block() {
ActiveLidarrBlock::DeleteArtistConfirmPrompt => {
if self.app.data.lidarr_data.prompt_confirm {
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::DeleteArtist(self.build_delete_artist_params()));
self.app.should_refresh = true;
} else {
self.app.data.lidarr_data.reset_delete_artist_preferences();
}
self.app.pop_navigation_stack();
}
ActiveLidarrBlock::DeleteArtistToggleDeleteFile => {
self.app.data.lidarr_data.delete_artist_files =
!self.app.data.lidarr_data.delete_artist_files;
}
ActiveLidarrBlock::DeleteArtistToggleAddListExclusion => {
self.app.data.lidarr_data.add_import_list_exclusion =
!self.app.data.lidarr_data.add_import_list_exclusion;
}
_ => (),
}
}
}
fn handle_esc(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.reset_delete_artist_preferences();
self.app.data.lidarr_data.prompt_confirm = false;
}
}
fn handle_char_key_event(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt
&& self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::DeleteArtistConfirmPrompt
&& matches_key!(confirm, self.key)
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::DeleteArtist(self.build_delete_artist_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()
}
}
@@ -0,0 +1,410 @@
#[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::lidarr_handlers::library::delete_artist_handler::DeleteArtistHandler;
use crate::models::lidarr_models::{Artist, DeleteArtistParams};
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_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::lidarr::lidarr_data::DELETE_ARTIST_SELECTION_BLOCKS;
use super::*;
#[rstest]
fn test_delete_artist_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) {
let mut app = App::test_default();
app.data.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS);
app.data.lidarr_data.selected_block.down();
DeleteArtistHandler::new(key, &mut app, ActiveLidarrBlock::DeleteArtistPrompt, None).handle();
if key == Key::Up {
assert_eq!(
app.data.lidarr_data.selected_block.get_active_block(),
ActiveLidarrBlock::DeleteArtistToggleDeleteFile
);
} else {
assert_eq!(
app.data.lidarr_data.selected_block.get_active_block(),
ActiveLidarrBlock::DeleteArtistConfirmPrompt
);
}
}
#[rstest]
fn test_delete_artist_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.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS);
app.data.lidarr_data.selected_block.down();
DeleteArtistHandler::new(key, &mut app, ActiveLidarrBlock::DeleteArtistPrompt, None).handle();
assert_eq!(
app.data.lidarr_data.selected_block.get_active_block(),
ActiveLidarrBlock::DeleteArtistToggleAddListExclusion
);
}
}
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(ActiveLidarrBlock::DeleteArtistPrompt.into());
DeleteArtistHandler::new(key, &mut app, ActiveLidarrBlock::DeleteArtistPrompt, None).handle();
assert!(app.data.lidarr_data.prompt_confirm);
DeleteArtistHandler::new(key, &mut app, ActiveLidarrBlock::DeleteArtistPrompt, None).handle();
assert!(!app.data.lidarr_data.prompt_confirm);
}
}
mod test_handle_submit {
use pretty_assertions::assert_eq;
use crate::models::BlockSelectionState;
use crate::models::servarr_data::lidarr::lidarr_data::DELETE_ARTIST_SELECTION_BLOCKS;
use crate::network::lidarr_network::LidarrEvent;
use super::*;
use crate::assert_navigation_popped;
const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key;
#[test]
fn test_delete_artist_prompt_prompt_decline_submit() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into());
app.data.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS);
app
.data
.lidarr_data
.selected_block
.set_index(0, DELETE_ARTIST_SELECTION_BLOCKS.len() - 1);
app.data.lidarr_data.delete_artist_files = true;
app.data.lidarr_data.add_import_list_exclusion = true;
DeleteArtistHandler::new(
SUBMIT_KEY,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
)
.handle();
assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into());
assert_none!(app.data.lidarr_data.prompt_confirm_action);
assert!(!app.data.lidarr_data.prompt_confirm);
assert!(!app.data.lidarr_data.delete_artist_files);
assert!(!app.data.lidarr_data.add_import_list_exclusion);
}
#[test]
fn test_delete_artist_confirm_prompt_prompt_confirmation_submit() {
let mut app = App::test_default();
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_artist_files = true;
app.data.lidarr_data.add_import_list_exclusion = true;
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
let expected_delete_artist_params = DeleteArtistParams {
id: 0,
delete_files: true,
add_import_list_exclusion: true,
};
app.data.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS);
app
.data
.lidarr_data
.selected_block
.set_index(0, DELETE_ARTIST_SELECTION_BLOCKS.len() - 1);
DeleteArtistHandler::new(
SUBMIT_KEY,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
)
.handle();
assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into());
assert_eq!(
app.data.lidarr_data.prompt_confirm_action,
Some(LidarrEvent::DeleteArtist(expected_delete_artist_params))
);
assert!(app.should_refresh);
assert!(app.data.lidarr_data.prompt_confirm);
assert!(!app.data.lidarr_data.delete_artist_files);
assert!(!app.data.lidarr_data.add_import_list_exclusion);
}
#[test]
fn test_delete_artist_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
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_artist_files = true;
app.data.lidarr_data.add_import_list_exclusion = true;
DeleteArtistHandler::new(
SUBMIT_KEY,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
)
.handle();
assert_eq!(
app.get_current_route(),
ActiveLidarrBlock::DeleteArtistPrompt.into()
);
assert_none!(app.data.lidarr_data.prompt_confirm_action);
assert!(!app.should_refresh);
assert!(app.data.lidarr_data.prompt_confirm);
assert!(app.data.lidarr_data.delete_artist_files);
assert!(app.data.lidarr_data.add_import_list_exclusion);
}
#[test]
fn test_delete_artist_toggle_delete_files_submit() {
let current_route = ActiveLidarrBlock::DeleteArtistPrompt.into();
let mut app = App::test_default();
app.data.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS);
app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into());
DeleteArtistHandler::new(
SUBMIT_KEY,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
)
.handle();
assert_eq!(app.get_current_route(), current_route);
assert_eq!(app.data.lidarr_data.delete_artist_files, true);
DeleteArtistHandler::new(
SUBMIT_KEY,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
)
.handle();
assert_eq!(app.get_current_route(), current_route);
assert_eq!(app.data.lidarr_data.delete_artist_files, false);
}
}
mod test_handle_esc {
use super::*;
use crate::assert_navigation_popped;
use rstest::rstest;
const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key;
#[rstest]
fn test_delete_artist_prompt_esc(#[values(true, false)] is_ready: bool) {
let mut app = App::test_default();
app.is_loading = is_ready;
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_artist_files = true;
app.data.lidarr_data.add_import_list_exclusion = true;
DeleteArtistHandler::new(
ESC_KEY,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
)
.handle();
assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert!(!app.data.lidarr_data.delete_artist_files);
assert!(!app.data.lidarr_data.add_import_list_exclusion);
}
}
mod test_handle_key_char {
use crate::{
assert_navigation_popped,
models::{
BlockSelectionState, servarr_data::lidarr::lidarr_data::DELETE_ARTIST_SELECTION_BLOCKS,
},
network::lidarr_network::LidarrEvent,
};
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_delete_artist_confirm_prompt_prompt_confirm() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into());
app.data.lidarr_data.delete_artist_files = true;
app.data.lidarr_data.add_import_list_exclusion = true;
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
let expected_delete_artist_params = DeleteArtistParams {
id: 0,
delete_files: true,
add_import_list_exclusion: true,
};
app.data.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS);
app
.data
.lidarr_data
.selected_block
.set_index(0, DELETE_ARTIST_SELECTION_BLOCKS.len() - 1);
DeleteArtistHandler::new(
DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
)
.handle();
assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into());
assert_eq!(
app.data.lidarr_data.prompt_confirm_action,
Some(LidarrEvent::DeleteArtist(expected_delete_artist_params))
);
assert!(app.should_refresh);
assert!(app.data.lidarr_data.prompt_confirm);
assert!(!app.data.lidarr_data.delete_artist_files);
assert!(!app.data.lidarr_data.add_import_list_exclusion);
}
}
#[test]
fn test_delete_artist_handler_accepts() {
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if DELETE_ARTIST_BLOCKS.contains(&active_lidarr_block) {
assert!(DeleteArtistHandler::accepts(active_lidarr_block));
} else {
assert!(!DeleteArtistHandler::accepts(active_lidarr_block));
}
});
}
#[rstest]
fn test_delete_artist_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 = DeleteArtistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::default(),
None,
);
assert_eq!(
handler.ignore_special_keys(),
ignore_special_keys_for_textbox_input
);
}
#[test]
fn test_build_delete_artist_params() {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.data.lidarr_data.delete_artist_files = true;
app.data.lidarr_data.add_import_list_exclusion = true;
let expected_delete_artist_params = DeleteArtistParams {
id: 0,
delete_files: true,
add_import_list_exclusion: true,
};
let delete_artist_params = DeleteArtistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
)
.build_delete_artist_params();
assert_eq!(delete_artist_params, expected_delete_artist_params);
assert!(!app.data.lidarr_data.delete_artist_files);
assert!(!app.data.lidarr_data.add_import_list_exclusion);
}
#[test]
fn test_delete_artist_handler_not_ready_when_loading() {
let mut app = App::test_default();
app.is_loading = true;
let handler = DeleteArtistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_delete_artist_handler_ready_when_not_loading() {
let mut app = App::test_default();
app.is_loading = false;
let handler = DeleteArtistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
);
assert!(handler.is_ready());
}
}
@@ -0,0 +1,455 @@
use crate::app::App;
use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::models::lidarr_models::EditArtistParams;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS};
use crate::models::servarr_data::lidarr::modals::EditArtistModal;
use crate::models::{Route, Scrollable};
use crate::network::lidarr_network::LidarrEvent;
use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key};
#[cfg(test)]
#[path = "edit_artist_handler_tests.rs"]
mod edit_artist_handler_tests;
pub(in crate::handlers::lidarr_handlers) struct EditArtistHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_lidarr_block: ActiveLidarrBlock,
context: Option<ActiveLidarrBlock>,
}
impl EditArtistHandler<'_, '_> {
fn build_edit_artist_params(&mut self) -> EditArtistParams {
let edit_artist_modal = self
.app
.data
.lidarr_data
.edit_artist_modal
.take()
.expect("EditArtistModal is None");
let artist_id = self.app.data.lidarr_data.artists.current_selection().id;
let tags = edit_artist_modal.tags.text;
let EditArtistModal {
monitored,
path,
monitor_list,
quality_profile_list,
metadata_profile_list,
..
} = edit_artist_modal;
let quality_profile = quality_profile_list.current_selection();
let quality_profile_id = *self
.app
.data
.lidarr_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
.lidarr_data
.metadata_profile_map
.iter()
.filter(|(_, value)| *value == metadata_profile)
.map(|(key, _)| key)
.next()
.unwrap();
EditArtistParams {
artist_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),
..EditArtistParams::default()
}
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for EditArtistHandler<'a, 'b> {
fn accepts(active_block: ActiveLidarrBlock) -> bool {
EDIT_ARTIST_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<ActiveLidarrBlock>,
) -> EditArtistHandler<'a, 'b> {
EditArtistHandler {
key,
app,
active_lidarr_block: active_block,
context,
}
}
fn get_key(&self) -> Key {
self.key
}
fn is_ready(&self) -> bool {
!self.app.is_loading && self.app.data.lidarr_data.edit_artist_modal.is_some()
}
fn handle_scroll_up(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::EditArtistSelectMonitorNewItems => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.monitor_list
.scroll_up(),
ActiveLidarrBlock::EditArtistSelectQualityProfile => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.quality_profile_list
.scroll_up(),
ActiveLidarrBlock::EditArtistSelectMetadataProfile => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.metadata_profile_list
.scroll_up(),
ActiveLidarrBlock::EditArtistPrompt => self.app.data.lidarr_data.selected_block.up(),
_ => (),
}
}
fn handle_scroll_down(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::EditArtistSelectMonitorNewItems => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.monitor_list
.scroll_down(),
ActiveLidarrBlock::EditArtistSelectQualityProfile => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.quality_profile_list
.scroll_down(),
ActiveLidarrBlock::EditArtistSelectMetadataProfile => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.metadata_profile_list
.scroll_down(),
ActiveLidarrBlock::EditArtistPrompt => self.app.data.lidarr_data.selected_block.down(),
_ => (),
}
}
fn handle_home(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::EditArtistSelectMonitorNewItems => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.monitor_list
.scroll_to_top(),
ActiveLidarrBlock::EditArtistSelectQualityProfile => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.quality_profile_list
.scroll_to_top(),
ActiveLidarrBlock::EditArtistSelectMetadataProfile => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.metadata_profile_list
.scroll_to_top(),
ActiveLidarrBlock::EditArtistPathInput => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.path
.scroll_home(),
ActiveLidarrBlock::EditArtistTagsInput => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.tags
.scroll_home(),
_ => (),
}
}
fn handle_end(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::EditArtistSelectMonitorNewItems => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.monitor_list
.scroll_to_bottom(),
ActiveLidarrBlock::EditArtistSelectQualityProfile => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.quality_profile_list
.scroll_to_bottom(),
ActiveLidarrBlock::EditArtistSelectMetadataProfile => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.metadata_profile_list
.scroll_to_bottom(),
ActiveLidarrBlock::EditArtistPathInput => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.path
.reset_offset(),
ActiveLidarrBlock::EditArtistTagsInput => self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.tags
.reset_offset(),
_ => (),
}
}
fn handle_delete(&mut self) {}
fn handle_left_right_action(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::EditArtistPrompt => handle_prompt_toggle(self.app, self.key),
ActiveLidarrBlock::EditArtistPathInput => {
handle_text_box_left_right_keys!(
self,
self.key,
self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.path
)
}
ActiveLidarrBlock::EditArtistTagsInput => {
handle_text_box_left_right_keys!(
self,
self.key,
self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.tags
)
}
_ => (),
}
}
fn handle_submit(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::EditArtistPrompt => {
match self.app.data.lidarr_data.selected_block.get_active_block() {
ActiveLidarrBlock::EditArtistConfirmPrompt => {
if self.app.data.lidarr_data.prompt_confirm {
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::EditArtist(self.build_edit_artist_params()));
self.app.should_refresh = true;
}
self.app.pop_navigation_stack();
}
ActiveLidarrBlock::EditArtistSelectMonitorNewItems
| ActiveLidarrBlock::EditArtistSelectQualityProfile
| ActiveLidarrBlock::EditArtistSelectMetadataProfile => self.app.push_navigation_stack(
(
self.app.data.lidarr_data.selected_block.get_active_block(),
self.context,
)
.into(),
),
ActiveLidarrBlock::EditArtistPathInput | ActiveLidarrBlock::EditArtistTagsInput => {
self.app.push_navigation_stack(
(
self.app.data.lidarr_data.selected_block.get_active_block(),
self.context,
)
.into(),
);
self.app.ignore_special_keys_for_textbox_input = true;
}
ActiveLidarrBlock::EditArtistToggleMonitored => {
self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.monitored = Some(
!self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.monitored
.unwrap_or_default(),
)
}
_ => (),
}
}
ActiveLidarrBlock::EditArtistSelectMonitorNewItems
| ActiveLidarrBlock::EditArtistSelectQualityProfile
| ActiveLidarrBlock::EditArtistSelectMetadataProfile => self.app.pop_navigation_stack(),
ActiveLidarrBlock::EditArtistPathInput | ActiveLidarrBlock::EditArtistTagsInput => {
self.app.pop_navigation_stack();
self.app.ignore_special_keys_for_textbox_input = false;
}
_ => (),
}
}
fn handle_esc(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::EditArtistTagsInput | ActiveLidarrBlock::EditArtistPathInput => {
self.app.pop_navigation_stack();
self.app.ignore_special_keys_for_textbox_input = false;
}
ActiveLidarrBlock::EditArtistPrompt => {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.edit_artist_modal = None;
self.app.data.lidarr_data.prompt_confirm = false;
}
ActiveLidarrBlock::EditArtistSelectMonitorNewItems
| ActiveLidarrBlock::EditArtistSelectQualityProfile
| ActiveLidarrBlock::EditArtistSelectMetadataProfile => self.app.pop_navigation_stack(),
_ => (),
}
}
fn handle_char_key_event(&mut self) {
let key = self.key;
match self.active_lidarr_block {
ActiveLidarrBlock::EditArtistPathInput => {
handle_text_box_keys!(
self,
key,
self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.path
)
}
ActiveLidarrBlock::EditArtistTagsInput => {
handle_text_box_keys!(
self,
key,
self
.app
.data
.lidarr_data
.edit_artist_modal
.as_mut()
.unwrap()
.tags
)
}
ActiveLidarrBlock::EditArtistPrompt => {
if self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::EditArtistConfirmPrompt
&& matches_key!(confirm, key)
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::EditArtist(self.build_edit_artist_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()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,664 @@
#[cfg(test)]
mod tests {
use std::cmp::Ordering;
use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest;
use serde_json::Number;
use strum::IntoEnumIterator;
use crate::app::App;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::handlers::KeyEventHandler;
use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options};
use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus};
use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS,
EDIT_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS,
};
use crate::models::servarr_data::lidarr::modals::EditArtistModal;
use crate::network::lidarr_network::LidarrEvent;
use crate::{
assert_modal_absent, assert_modal_present, assert_navigation_popped, assert_navigation_pushed,
};
#[test]
fn test_library_handler_accepts() {
let mut library_handler_blocks = Vec::new();
library_handler_blocks.extend(LIBRARY_BLOCKS);
library_handler_blocks.extend(DELETE_ARTIST_BLOCKS);
library_handler_blocks.extend(EDIT_ARTIST_BLOCKS);
library_handler_blocks.extend(ADD_ARTIST_BLOCKS);
ActiveLidarrBlock::iter().for_each(|lidarr_block| {
if library_handler_blocks.contains(&lidarr_block) {
assert!(LibraryHandler::accepts(lidarr_block));
} else {
assert!(!LibraryHandler::accepts(lidarr_block));
}
});
}
#[test]
fn test_artists_sorting_options_name() {
let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| {
a.artist_name
.text
.to_lowercase()
.cmp(&b.artist_name.text.to_lowercase())
};
let mut expected_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[0].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Name");
}
#[test]
fn test_artists_sorting_options_type() {
let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| {
a.artist_type
.as_ref()
.unwrap_or(&String::new())
.to_lowercase()
.cmp(
&b.artist_type
.as_ref()
.unwrap_or(&String::new())
.to_lowercase(),
)
};
let mut expected_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[1].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Type");
}
#[test]
fn test_artists_sorting_options_status() {
let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| {
a.status
.to_string()
.to_lowercase()
.cmp(&b.status.to_string().to_lowercase())
};
let mut expected_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[2].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Status");
}
#[test]
fn test_artists_sorting_options_quality_profile() {
let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering =
|a, b| a.quality_profile_id.cmp(&b.quality_profile_id);
let mut expected_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[3].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Quality Profile");
}
#[test]
fn test_artists_sorting_options_metadata_profile() {
let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering =
|a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id);
let mut expected_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[4].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Metadata Profile");
}
#[test]
fn test_artists_sorting_options_albums() {
let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| {
a.statistics
.as_ref()
.map_or(0, |stats| stats.album_count)
.cmp(&b.statistics.as_ref().map_or(0, |stats| stats.album_count))
};
let mut expected_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[5].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Albums");
}
#[test]
fn test_artists_sorting_options_tracks() {
let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| {
a.statistics
.as_ref()
.map_or(0, |stats| stats.track_count)
.cmp(&b.statistics.as_ref().map_or(0, |stats| stats.track_count))
};
let mut expected_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[6].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Tracks");
}
#[test]
fn test_artists_sorting_options_size() {
let expected_cmp_fn: fn(&Artist, &Artist) -> 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_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[7].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Size");
}
#[test]
fn test_artists_sorting_options_monitored() {
let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| a.monitored.cmp(&b.monitored);
let mut expected_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[8].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Monitored");
}
#[test]
fn test_artists_sorting_options_tags() {
let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| {
let a_str = a
.tags
.iter()
.map(|tag| tag.as_i64().unwrap().to_string())
.collect::<Vec<String>>()
.join(",");
let b_str = b
.tags
.iter()
.map(|tag| tag.as_i64().unwrap().to_string())
.collect::<Vec<String>>()
.join(",");
a_str.cmp(&b_str)
};
let mut expected_artists_vec = artists_vec();
expected_artists_vec.sort_by(expected_cmp_fn);
let sort_option = artists_sorting_options()[9].clone();
let mut sorted_artists_vec = artists_vec();
sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_artists_vec, expected_artists_vec);
assert_str_eq!(sort_option.name, "Tags");
}
#[test]
fn test_toggle_monitoring_key() {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.is_routing = false;
LibraryHandler::new(
DEFAULT_KEYBINDINGS.toggle_monitoring.key,
&mut app,
ActiveLidarrBlock::Artists,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
assert!(app.data.lidarr_data.prompt_confirm);
assert!(app.is_routing);
assert_some_eq_x!(
&app.data.lidarr_data.prompt_confirm_action,
&LidarrEvent::ToggleArtistMonitoring(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(ActiveLidarrBlock::Artists.into());
app.is_routing = false;
LibraryHandler::new(
DEFAULT_KEYBINDINGS.toggle_monitoring.key,
&mut app,
ActiveLidarrBlock::Artists,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_modal_absent!(app.data.lidarr_data.prompt_confirm_action);
assert!(!app.is_routing);
}
#[test]
fn test_update_all_artists_key() {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
LibraryHandler::new(
DEFAULT_KEYBINDINGS.update.key,
&mut app,
ActiveLidarrBlock::Artists,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::UpdateAllArtistsPrompt.into());
}
#[test]
fn test_update_all_artists_key_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
LibraryHandler::new(
DEFAULT_KEYBINDINGS.update.key,
&mut app,
ActiveLidarrBlock::Artists,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
}
#[test]
fn test_update_all_artists_prompt_confirm_submit() {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.data.lidarr_data.prompt_confirm = true;
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into());
LibraryHandler::new(
DEFAULT_KEYBINDINGS.submit.key,
&mut app,
ActiveLidarrBlock::UpdateAllArtistsPrompt,
None,
)
.handle();
assert!(app.data.lidarr_data.prompt_confirm);
assert_some_eq_x!(
&app.data.lidarr_data.prompt_confirm_action,
&LidarrEvent::UpdateAllArtists
);
assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into());
}
#[test]
fn test_update_all_artists_prompt_decline_submit() {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into());
LibraryHandler::new(
DEFAULT_KEYBINDINGS.submit.key,
&mut app,
ActiveLidarrBlock::UpdateAllArtistsPrompt,
None,
)
.handle();
assert!(!app.data.lidarr_data.prompt_confirm);
assert_none!(app.data.lidarr_data.prompt_confirm_action);
assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into());
}
#[test]
fn test_update_all_artists_prompt_esc() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into());
app.data.lidarr_data.prompt_confirm = true;
LibraryHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::UpdateAllArtistsPrompt,
None,
)
.handle();
assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into());
assert!(!app.data.lidarr_data.prompt_confirm);
}
#[test]
fn test_update_all_artists_prompt_left_right() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into());
LibraryHandler::new(
DEFAULT_KEYBINDINGS.left.key,
&mut app,
ActiveLidarrBlock::UpdateAllArtistsPrompt,
None,
)
.handle();
assert!(app.data.lidarr_data.prompt_confirm);
LibraryHandler::new(
DEFAULT_KEYBINDINGS.right.key,
&mut app,
ActiveLidarrBlock::UpdateAllArtistsPrompt,
None,
)
.handle();
assert!(!app.data.lidarr_data.prompt_confirm);
}
#[test]
fn test_update_all_artists_prompt_confirm_key() {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into());
LibraryHandler::new(
DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
ActiveLidarrBlock::UpdateAllArtistsPrompt,
None,
)
.handle();
assert!(app.data.lidarr_data.prompt_confirm);
assert_some_eq_x!(
&app.data.lidarr_data.prompt_confirm_action,
&LidarrEvent::UpdateAllArtists
);
assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into());
}
fn artists_vec() -> Vec<Artist> {
vec![
Artist {
id: 3,
artist_name: "Test Artist 1".into(),
artist_type: Some("Group".to_owned()),
status: ArtistStatus::Ended,
quality_profile_id: 1,
metadata_profile_id: 1,
monitored: false,
tags: vec![Number::from(1), Number::from(2)],
statistics: Some(ArtistStatistics {
album_count: 5,
track_count: 50,
size_on_disk: 789,
..ArtistStatistics::default()
}),
..Artist::default()
},
Artist {
id: 2,
artist_name: "Test Artist 2".into(),
artist_type: Some("Solo".to_owned()),
status: ArtistStatus::Continuing,
quality_profile_id: 2,
metadata_profile_id: 2,
monitored: false,
tags: vec![Number::from(1), Number::from(3)],
statistics: Some(ArtistStatistics {
album_count: 10,
track_count: 100,
size_on_disk: 456,
..ArtistStatistics::default()
}),
..Artist::default()
},
Artist {
id: 1,
artist_name: "Test Artist 3".into(),
artist_type: None,
status: ArtistStatus::Deleted,
quality_profile_id: 3,
metadata_profile_id: 3,
monitored: true,
tags: vec![Number::from(2), Number::from(3)],
statistics: Some(ArtistStatistics {
album_count: 3,
track_count: 30,
size_on_disk: 123,
..ArtistStatistics::default()
}),
..Artist::default()
},
]
}
#[rstest]
fn test_delegates_add_artist_blocks_to_add_artist_handler(
#[values(
ActiveLidarrBlock::AddArtistSearchInput,
ActiveLidarrBlock::AddArtistEmptySearchResults,
ActiveLidarrBlock::AddArtistSearchResults
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default_fully_populated();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(active_lidarr_block.into());
LibraryHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
}
#[test]
fn test_delegates_delete_artist_blocks_to_delete_artist_handler() {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into());
LibraryHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::DeleteArtistPrompt,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
}
#[rstest]
fn test_delegates_edit_artist_blocks_to_edit_artist_handler(
#[values(
ActiveLidarrBlock::EditArtistPrompt,
ActiveLidarrBlock::EditArtistSelectMetadataProfile,
ActiveLidarrBlock::EditArtistSelectMonitorNewItems,
ActiveLidarrBlock::EditArtistSelectQualityProfile,
ActiveLidarrBlock::EditArtistTagsInput,
ActiveLidarrBlock::EditArtistPathInput
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default());
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(active_lidarr_block.into());
LibraryHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
}
#[test]
fn test_edit_key() {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.data.lidarr_data.quality_profile_map =
bimap::BiMap::from_iter([(0i64, "Default Quality".to_owned())]);
app.data.lidarr_data.metadata_profile_map =
bimap::BiMap::from_iter([(0i64, "Default Metadata".to_owned())]);
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
LibraryHandler::new(
DEFAULT_KEYBINDINGS.edit.key,
&mut app,
ActiveLidarrBlock::Artists,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::EditArtistPrompt.into());
assert_modal_present!(app.data.lidarr_data.edit_artist_modal);
assert_eq!(
app.data.lidarr_data.selected_block.blocks,
EDIT_ARTIST_SELECTION_BLOCKS
);
}
#[test]
fn test_edit_key_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
LibraryHandler::new(
DEFAULT_KEYBINDINGS.edit.key,
&mut app,
ActiveLidarrBlock::Artists,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
assert_modal_absent!(app.data.lidarr_data.edit_artist_modal);
}
#[test]
fn test_refresh_key() {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
LibraryHandler::new(
DEFAULT_KEYBINDINGS.refresh.key,
&mut app,
ActiveLidarrBlock::Artists,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
assert!(app.should_refresh);
}
}
+317
View File
@@ -0,0 +1,317 @@
use crate::{
app::App,
event::Key,
handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle},
matches_key,
models::{
BlockSelectionState, HorizontallyScrollableText,
lidarr_models::Artist,
servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS,
LIBRARY_BLOCKS,
},
stateful_table::SortOption,
},
network::lidarr_network::LidarrEvent,
};
use super::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
mod add_artist_handler;
mod delete_artist_handler;
mod edit_artist_handler;
use crate::models::Route;
pub(in crate::handlers::lidarr_handlers) use add_artist_handler::AddArtistHandler;
pub(in crate::handlers::lidarr_handlers) use delete_artist_handler::DeleteArtistHandler;
pub(in crate::handlers::lidarr_handlers) use edit_artist_handler::EditArtistHandler;
#[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_lidarr_block: ActiveLidarrBlock,
context: Option<ActiveLidarrBlock>,
}
impl LibraryHandler<'_, '_> {
fn extract_artist_id(&self) -> i64 {
self.app.data.lidarr_data.artists.current_selection().id
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, 'b> {
fn handle(&mut self) {
let artists_table_handling_config = TableHandlingConfig::new(ActiveLidarrBlock::Artists.into())
.sorting_block(ActiveLidarrBlock::ArtistsSortPrompt.into())
.sort_options(artists_sorting_options())
.searching_block(ActiveLidarrBlock::SearchArtists.into())
.search_error_block(ActiveLidarrBlock::SearchArtistsError.into())
.search_field_fn(|artist| &artist.artist_name.text)
.filtering_block(ActiveLidarrBlock::FilterArtists.into())
.filter_error_block(ActiveLidarrBlock::FilterArtistsError.into())
.filter_field_fn(|artist| &artist.artist_name.text);
if !handle_table(
self,
|app| &mut app.data.lidarr_data.artists,
artists_table_handling_config,
) {
match self.active_lidarr_block {
_ if AddArtistHandler::accepts(self.active_lidarr_block) => {
AddArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context)
.handle();
}
_ if DeleteArtistHandler::accepts(self.active_lidarr_block) => {
DeleteArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context)
.handle();
}
_ if EditArtistHandler::accepts(self.active_lidarr_block) => {
EditArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context)
.handle();
}
_ => self.handle_key_event(),
}
}
}
fn accepts(active_block: ActiveLidarrBlock) -> bool {
AddArtistHandler::accepts(active_block)
|| DeleteArtistHandler::accepts(active_block)
|| EditArtistHandler::accepts(active_block)
|| 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: ActiveLidarrBlock,
context: Option<ActiveLidarrBlock>,
) -> LibraryHandler<'a, 'b> {
LibraryHandler {
key,
app,
active_lidarr_block: active_block,
context,
}
}
fn get_key(&self) -> Key {
self.key
}
fn is_ready(&self) -> bool {
!self.app.is_loading && !self.app.data.lidarr_data.artists.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_lidarr_block == ActiveLidarrBlock::Artists {
self
.app
.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into());
self.app.data.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS);
}
}
fn handle_left_right_action(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::Artists => handle_change_tab_left_right_keys(self.app, self.key),
ActiveLidarrBlock::UpdateAllArtistsPrompt => handle_prompt_toggle(self.app, self.key),
_ => (),
}
}
fn handle_submit(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::UpdateAllArtistsPrompt {
if self.app.data.lidarr_data.prompt_confirm {
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::UpdateAllArtists);
}
self.app.pop_navigation_stack();
}
}
fn handle_esc(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::UpdateAllArtistsPrompt => {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.prompt_confirm = false;
}
_ => {
handle_clear_errors(self.app);
}
}
}
fn handle_char_key_event(&mut self) {
let key = self.key;
match self.active_lidarr_block {
ActiveLidarrBlock::Artists => match key {
_ if matches_key!(add, key) => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into());
self.app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default());
self.app.ignore_special_keys_for_textbox_input = true;
}
_ if matches_key!(toggle_monitoring, key) => {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(
LidarrEvent::ToggleArtistMonitoring(self.extract_artist_id()),
);
self
.app
.pop_and_push_navigation_stack(self.active_lidarr_block.into());
}
_ if matches_key!(edit, key) => {
self.app.data.lidarr_data.edit_artist_modal = Some((&self.app.data.lidarr_data).into());
self
.app
.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into());
self.app.data.lidarr_data.selected_block =
BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS);
}
_ if matches_key!(update, key) => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into());
}
_ if matches_key!(refresh, key) => {
self.app.should_refresh = true;
}
_ => (),
},
ActiveLidarrBlock::UpdateAllArtistsPrompt => {
if matches_key!(confirm, key) {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::UpdateAllArtists);
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
fn app_mut(&mut self) -> &mut App<'b> {
self.app
}
fn current_route(&self) -> Route {
self.app.get_current_route()
}
}
fn artists_sorting_options() -> Vec<SortOption<Artist>> {
vec![
SortOption {
name: "Name",
cmp_fn: Some(|a, b| {
a.artist_name
.text
.to_lowercase()
.cmp(&b.artist_name.text.to_lowercase())
}),
},
SortOption {
name: "Type",
cmp_fn: Some(|a, b| {
a.artist_type
.as_ref()
.unwrap_or(&String::new())
.to_lowercase()
.cmp(
&b.artist_type
.as_ref()
.unwrap_or(&String::new())
.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: "Albums",
cmp_fn: Some(|a, b| {
a.statistics
.as_ref()
.map_or(0, |stats| stats.album_count)
.cmp(&b.statistics.as_ref().map_or(0, |stats| stats.album_count))
}),
},
SortOption {
name: "Tracks",
cmp_fn: Some(|a, b| {
a.statistics
.as_ref()
.map_or(0, |stats| stats.track_count)
.cmp(&b.statistics.as_ref().map_or(0, |stats| stats.track_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::<Vec<String>>()
.join(",");
let b_str = b
.tags
.iter()
.map(|tag| tag.as_i64().unwrap().to_string())
.collect::<Vec<String>>()
.join(",");
a_str.cmp(&b_str)
}),
},
]
}
@@ -0,0 +1,95 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::handlers::KeyEventHandler;
use crate::handlers::lidarr_handlers::LidarrHandler;
use crate::models::lidarr_models::Artist;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::servarr_data::lidarr::modals::EditArtistModal;
use pretty_assertions::assert_eq;
use rstest::rstest;
use strum::IntoEnumIterator;
#[rstest]
fn test_lidarr_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 = LidarrHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::default(),
None,
);
assert_eq!(
handler.ignore_special_keys(),
ignore_special_keys_for_textbox_input
);
}
#[test]
fn test_lidarr_handler_is_ready() {
let mut app = App::test_default();
app.is_loading = true;
let handler = LidarrHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::default(),
None,
);
assert!(handler.is_ready());
}
#[test]
fn test_lidarr_handler_accepts() {
for lidarr_block in ActiveLidarrBlock::iter() {
assert!(LidarrHandler::accepts(lidarr_block));
}
}
#[rstest]
fn test_delegates_library_blocks_to_library_handler(
#[values(
ActiveLidarrBlock::Artists,
ActiveLidarrBlock::ArtistsSortPrompt,
ActiveLidarrBlock::FilterArtists,
ActiveLidarrBlock::FilterArtistsError,
ActiveLidarrBlock::SearchArtists,
ActiveLidarrBlock::SearchArtistsError,
ActiveLidarrBlock::UpdateAllArtistsPrompt,
ActiveLidarrBlock::DeleteArtistPrompt,
ActiveLidarrBlock::EditArtistPrompt,
ActiveLidarrBlock::EditArtistPathInput,
ActiveLidarrBlock::EditArtistSelectMetadataProfile,
ActiveLidarrBlock::EditArtistSelectMonitorNewItems,
ActiveLidarrBlock::EditArtistSelectQualityProfile,
ActiveLidarrBlock::EditArtistTagsInput
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app
.data
.lidarr_data
.artists
.set_items(vec![Artist::default()]);
app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default());
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(active_lidarr_block.into());
LidarrHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
}
}
+102
View File
@@ -0,0 +1,102 @@
use library::LibraryHandler;
use super::KeyEventHandler;
use crate::models::Route;
use crate::{
app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock,
};
mod library;
#[cfg(test)]
#[path = "lidarr_handler_tests.rs"]
mod lidarr_handler_tests;
pub(super) struct LidarrHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_lidarr_block: ActiveLidarrBlock,
context: Option<ActiveLidarrBlock>,
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b> {
fn handle(&mut self) {
match self.active_lidarr_block {
_ if LibraryHandler::accepts(self.active_lidarr_block) => {
LibraryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle();
}
_ => self.handle_key_event(),
}
}
fn accepts(_active_block: ActiveLidarrBlock) -> bool {
true
}
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<ActiveLidarrBlock>,
) -> LidarrHandler<'a, 'b> {
LidarrHandler {
key,
app,
active_lidarr_block: active_block,
context,
}
}
fn get_key(&self) -> Key {
self.key
}
fn is_ready(&self) -> bool {
true
}
fn handle_scroll_up(&mut self) {}
fn handle_scroll_down(&mut self) {}
fn handle_home(&mut self) {}
fn handle_end(&mut self) {}
fn handle_delete(&mut self) {}
fn handle_left_right_action(&mut self) {}
fn handle_submit(&mut self) {}
fn handle_esc(&mut self) {}
fn handle_char_key_event(&mut self) {}
fn app_mut(&mut self) -> &mut App<'b> {
self.app
}
fn current_route(&self) -> Route {
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.lidarr_data.main_tabs.previous();
app.pop_and_push_navigation_stack(app.data.lidarr_data.main_tabs.get_active_route());
}
_ if matches_key!(right, key, app.ignore_special_keys_for_textbox_input) => {
app.data.lidarr_data.main_tabs.next();
app.pop_and_push_navigation_stack(app.data.lidarr_data.main_tabs.get_active_route());
}
_ => (),
}
}
+8
View File
@@ -1,3 +1,4 @@
use lidarr_handlers::LidarrHandler;
use radarr_handlers::RadarrHandler; use radarr_handlers::RadarrHandler;
use sonarr_handlers::SonarrHandler; use sonarr_handlers::SonarrHandler;
@@ -15,6 +16,7 @@ use crate::models::stateful_table::StatefulTable;
use crate::models::{HorizontallyScrollableText, Route}; use crate::models::{HorizontallyScrollableText, Route};
mod keybinding_handler; mod keybinding_handler;
mod lidarr_handlers;
mod radarr_handlers; mod radarr_handlers;
mod sonarr_handlers; mod sonarr_handlers;
@@ -125,6 +127,9 @@ pub fn handle_events(key: Key, app: &mut App<'_>) {
Route::Sonarr(active_sonarr_block, context) => { Route::Sonarr(active_sonarr_block, context) => {
SonarrHandler::new(key, app, active_sonarr_block, context).handle() SonarrHandler::new(key, app, active_sonarr_block, context).handle()
} }
Route::Lidarr(active_lidarr_block, context) => {
LidarrHandler::new(key, app, active_lidarr_block, context).handle()
}
_ => (), _ => (),
} }
} }
@@ -187,6 +192,9 @@ fn handle_prompt_toggle(app: &mut App<'_>, key: Key) {
Route::Sonarr(_, _) => { Route::Sonarr(_, _) => {
app.data.sonarr_data.prompt_confirm = !app.data.sonarr_data.prompt_confirm app.data.sonarr_data.prompt_confirm = !app.data.sonarr_data.prompt_confirm
} }
Route::Lidarr(_, _) => {
app.data.lidarr_data.prompt_confirm = !app.data.lidarr_data.prompt_confirm
}
_ => (), _ => (),
}, },
_ => (), _ => (),
@@ -4,6 +4,7 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::Route;
use crate::models::radarr_models::BlocklistItem; use crate::models::radarr_models::BlocklistItem;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS};
use crate::models::stateful_table::SortOption; use crate::models::stateful_table::SortOption;
@@ -178,7 +179,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a,
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -3,12 +3,12 @@ use crate::event::Key;
use crate::handlers::KeyEventHandler; use crate::handlers::KeyEventHandler;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::matches_key; use crate::matches_key;
use crate::models::BlockSelectionState;
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS,
EDIT_COLLECTION_SELECTION_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS,
}; };
use crate::models::stateful_table::StatefulTable; use crate::models::stateful_table::StatefulTable;
use crate::models::{BlockSelectionState, Route};
#[cfg(test)] #[cfg(test)]
#[path = "collection_details_handler_tests.rs"] #[path = "collection_details_handler_tests.rs"]
@@ -148,7 +148,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -1,10 +1,10 @@
use crate::app::App; use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::models::Scrollable;
use crate::models::radarr_models::EditCollectionParams; use crate::models::radarr_models::EditCollectionParams;
use crate::models::servarr_data::radarr::modals::EditCollectionModal; use crate::models::servarr_data::radarr::modals::EditCollectionModal;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_COLLECTION_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_COLLECTION_BLOCKS};
use crate::models::{Route, Scrollable};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key};
@@ -376,7 +376,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -6,12 +6,12 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::BlockSelectionState;
use crate::models::radarr_models::Collection; use crate::models::radarr_models::Collection;
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, COLLECTIONS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, ActiveRadarrBlock, COLLECTIONS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS,
}; };
use crate::models::stateful_table::SortOption; use crate::models::stateful_table::SortOption;
use crate::models::{BlockSelectionState, Route};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
mod collection_details_handler; mod collection_details_handler;
@@ -179,7 +179,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<'
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -4,6 +4,7 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::Route;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DOWNLOADS_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DOWNLOADS_BLOCKS};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
@@ -164,7 +165,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a,
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -1,6 +1,7 @@
use crate::app::App; use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::models::Route;
use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS};
use crate::models::servarr_models::EditIndexerParams; use crate::models::servarr_models::EditIndexerParams;
@@ -527,7 +528,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<'
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -1,6 +1,7 @@
use crate::app::App; use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::models::Route;
use crate::models::radarr_models::IndexerSettings; use crate::models::radarr_models::IndexerSettings;
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, INDEXER_SETTINGS_BLOCKS, ActiveRadarrBlock, INDEXER_SETTINGS_BLOCKS,
@@ -293,7 +294,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+2 -2
View File
@@ -7,11 +7,11 @@ use crate::handlers::radarr_handlers::indexers::test_all_indexers_handler::TestA
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::BlockSelectionState;
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, ActiveRadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS,
INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS,
}; };
use crate::models::{BlockSelectionState, Route};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
mod edit_indexer_handler; mod edit_indexer_handler;
@@ -212,7 +212,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a,
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -2,6 +2,7 @@ use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::KeyEventHandler; use crate::handlers::KeyEventHandler;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::models::Route;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
#[cfg(test)] #[cfg(test)]
@@ -101,7 +102,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandl
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -7,7 +7,7 @@ use crate::models::servarr_data::radarr::modals::AddMovieModal;
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
ADD_MOVIE_BLOCKS, ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock, ADD_MOVIE_BLOCKS, ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock,
}; };
use crate::models::{BlockSelectionState, Scrollable}; use crate::models::{BlockSelectionState, Route, Scrollable};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys, matches_key};
@@ -558,7 +558,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a,
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -2,6 +2,7 @@ use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::Route;
use crate::models::radarr_models::DeleteMovieParams; use crate::models::radarr_models::DeleteMovieParams;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DELETE_MOVIE_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DELETE_MOVIE_BLOCKS};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
@@ -141,7 +142,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<'
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -1,10 +1,10 @@
use crate::app::App; use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::models::Scrollable;
use crate::models::radarr_models::EditMovieParams; use crate::models::radarr_models::EditMovieParams;
use crate::models::servarr_data::radarr::modals::EditMovieModal; use crate::models::servarr_data::radarr::modals::EditMovieModal;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_MOVIE_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_MOVIE_BLOCKS};
use crate::models::{Route, Scrollable};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key};
@@ -397,7 +397,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a,
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -11,7 +11,7 @@ use crate::models::servarr_data::radarr::radarr_data::{
}; };
use crate::models::servarr_models::Language; use crate::models::servarr_models::Language;
use crate::models::stateful_table::SortOption; use crate::models::stateful_table::SortOption;
use crate::models::{BlockSelectionState, Scrollable}; use crate::models::{BlockSelectionState, Route, Scrollable};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
#[cfg(test)] #[cfg(test)]
@@ -379,7 +379,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+2 -1
View File
@@ -6,6 +6,7 @@ use crate::handlers::radarr_handlers::indexers::IndexersHandler;
use crate::handlers::radarr_handlers::library::LibraryHandler; use crate::handlers::radarr_handlers::library::LibraryHandler;
use crate::handlers::radarr_handlers::root_folders::RootFoldersHandler; use crate::handlers::radarr_handlers::root_folders::RootFoldersHandler;
use crate::handlers::radarr_handlers::system::SystemHandler; use crate::handlers::radarr_handlers::system::SystemHandler;
use crate::models::Route;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::{App, Key, matches_key}; use crate::{App, Key, matches_key};
@@ -112,7 +113,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RadarrHandler<'a, 'b
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -3,9 +3,9 @@ use crate::event::Key;
use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::models::HorizontallyScrollableText;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS};
use crate::models::servarr_models::AddRootFolderBody; use crate::models::servarr_models::AddRootFolderBody;
use crate::models::{HorizontallyScrollableText, Route};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key};
@@ -231,7 +231,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<'
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+2 -2
View File
@@ -4,8 +4,8 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::radarr_handlers::system::system_details_handler::SystemDetailsHandler; use crate::handlers::radarr_handlers::system::system_details_handler::SystemDetailsHandler;
use crate::handlers::{KeyEventHandler, handle_clear_errors}; use crate::handlers::{KeyEventHandler, handle_clear_errors};
use crate::matches_key; use crate::matches_key;
use crate::models::Scrollable;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::{Route, Scrollable};
mod system_details_handler; mod system_details_handler;
@@ -129,7 +129,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemHandler<'a, 'b
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -2,10 +2,10 @@ use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::Scrollable;
use crate::models::radarr_models::RadarrTaskName; use crate::models::radarr_models::RadarrTaskName;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS};
use crate::models::stateful_list::StatefulList; use crate::models::stateful_list::StatefulList;
use crate::models::{Route, Scrollable};
use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::RadarrEvent;
#[cfg(test)] #[cfg(test)]
@@ -201,7 +201,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS};
use crate::models::sonarr_models::BlocklistItem; use crate::models::sonarr_models::BlocklistItem;
use crate::models::stateful_table::SortOption; use crate::models::stateful_table::SortOption;
@@ -178,7 +179,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a,
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS};
use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::SonarrEvent;
@@ -164,7 +165,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DownloadsHandler<'a,
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+2 -1
View File
@@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors}; use crate::handlers::{KeyEventHandler, handle_clear_errors};
use crate::matches_key; use crate::matches_key;
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS};
use crate::models::servarr_models::Language; use crate::models::servarr_models::Language;
use crate::models::sonarr_models::SonarrHistoryItem; use crate::models::sonarr_models::SonarrHistoryItem;
@@ -121,7 +122,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, '
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -1,6 +1,7 @@
use crate::app::App; use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::models::Route;
use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS};
use crate::models::servarr_models::EditIndexerParams; use crate::models::servarr_models::EditIndexerParams;
@@ -526,7 +527,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditIndexerHandler<'
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -1,6 +1,7 @@
use crate::app::App; use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::{ use crate::models::servarr_data::sonarr::sonarr_data::{
ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS, ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS,
}; };
@@ -202,7 +203,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexerSettingsHandl
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+2 -2
View File
@@ -7,11 +7,11 @@ use crate::handlers::sonarr_handlers::indexers::test_all_indexers_handler::TestA
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::BlockSelectionState;
use crate::models::servarr_data::sonarr::sonarr_data::{ use crate::models::servarr_data::sonarr::sonarr_data::{
ActiveSonarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, ActiveSonarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS,
INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS,
}; };
use crate::models::{BlockSelectionState, Route};
use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::SonarrEvent;
mod edit_indexer_handler; mod edit_indexer_handler;
@@ -211,7 +211,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexersHandler<'a,
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -2,6 +2,7 @@ use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::KeyEventHandler; use crate::handlers::KeyEventHandler;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
#[cfg(test)] #[cfg(test)]
@@ -101,7 +102,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for TestAllIndexersHandl
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -5,7 +5,7 @@ use crate::models::servarr_data::sonarr::sonarr_data::{
ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, ActiveSonarrBlock,
}; };
use crate::models::sonarr_models::{AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult}; use crate::models::sonarr_models::{AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult};
use crate::models::{BlockSelectionState, Scrollable}; use crate::models::{BlockSelectionState, Route, Scrollable};
use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::SonarrEvent;
use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys, matches_key};
@@ -625,7 +625,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a,
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -1,3 +1,4 @@
use crate::models::Route;
use crate::models::sonarr_models::DeleteSeriesParams; use crate::models::sonarr_models::DeleteSeriesParams;
use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::SonarrEvent;
use crate::{ use crate::{
@@ -143,7 +144,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DeleteSeriesHandler<
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -1,10 +1,10 @@
use crate::app::App; use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::models::Scrollable;
use crate::models::servarr_data::sonarr::modals::EditSeriesModal; use crate::models::servarr_data::sonarr::modals::EditSeriesModal;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_SERIES_BLOCKS}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_SERIES_BLOCKS};
use crate::models::sonarr_models::EditSeriesParams; use crate::models::sonarr_models::EditSeriesParams;
use crate::models::{Route, Scrollable};
use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::SonarrEvent;
use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key};
@@ -471,7 +471,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditSeriesHandler<'a
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::library::season_details_handler::releases_
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS};
use crate::models::sonarr_models::{SonarrRelease, SonarrReleaseDownloadBody}; use crate::models::sonarr_models::{SonarrRelease, SonarrReleaseDownloadBody};
use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::SonarrEvent;
@@ -370,7 +371,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EpisodeDetailsHandle
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+2 -1
View File
@@ -25,6 +25,7 @@ use crate::handlers::sonarr_handlers::library::episode_details_handler::EpisodeD
use crate::handlers::sonarr_handlers::library::season_details_handler::SeasonDetailsHandler; use crate::handlers::sonarr_handlers::library::season_details_handler::SeasonDetailsHandler;
use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::models::Route;
mod add_series_handler; mod add_series_handler;
mod delete_series_handler; mod delete_series_handler;
@@ -245,7 +246,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, '
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::history::history_sorting_options;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS};
use crate::models::servarr_models::Language; use crate::models::servarr_models::Language;
use crate::models::sonarr_models::{ use crate::models::sonarr_models::{
@@ -458,7 +459,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -4,11 +4,11 @@ use crate::handlers::sonarr_handlers::history::history_sorting_options;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::BlockSelectionState;
use crate::models::servarr_data::sonarr::sonarr_data::{ use crate::models::servarr_data::sonarr::sonarr_data::{
ActiveSonarrBlock, EDIT_SERIES_SELECTION_BLOCKS, SERIES_DETAILS_BLOCKS, ActiveSonarrBlock, EDIT_SERIES_SELECTION_BLOCKS, SERIES_DETAILS_BLOCKS,
}; };
use crate::models::sonarr_models::{Season, SonarrHistoryItem}; use crate::models::sonarr_models::{Season, SonarrHistoryItem};
use crate::models::{BlockSelectionState, Route};
use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::SonarrEvent;
#[cfg(test)] #[cfg(test)]
@@ -342,7 +342,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+3 -3
View File
@@ -6,12 +6,12 @@ use library::LibraryHandler;
use root_folders::RootFoldersHandler; use root_folders::RootFoldersHandler;
use system::SystemHandler; use system::SystemHandler;
use super::KeyEventHandler;
use crate::models::Route;
use crate::{ use crate::{
app::App, event::Key, matches_key, models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, app::App, event::Key, matches_key, models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock,
}; };
use super::KeyEventHandler;
mod blocklist; mod blocklist;
mod downloads; mod downloads;
mod history; mod history;
@@ -115,7 +115,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -3,9 +3,9 @@ use crate::event::Key;
use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::models::HorizontallyScrollableText;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS};
use crate::models::servarr_models::AddRootFolderBody; use crate::models::servarr_models::AddRootFolderBody;
use crate::models::{HorizontallyScrollableText, Route};
use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::SonarrEvent;
use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key};
@@ -229,7 +229,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for RootFoldersHandler<'
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+2 -2
View File
@@ -4,8 +4,8 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::sonarr_handlers::system::system_details_handler::SystemDetailsHandler; use crate::handlers::sonarr_handlers::system::system_details_handler::SystemDetailsHandler;
use crate::handlers::{KeyEventHandler, handle_clear_errors}; use crate::handlers::{KeyEventHandler, handle_clear_errors};
use crate::matches_key; use crate::matches_key;
use crate::models::Scrollable;
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::{Route, Scrollable};
mod system_details_handler; mod system_details_handler;
@@ -129,7 +129,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SystemHandler<'a, 'b
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
@@ -2,10 +2,10 @@ use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::matches_key; use crate::matches_key;
use crate::models::Scrollable;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS};
use crate::models::sonarr_models::SonarrTaskName; use crate::models::sonarr_models::SonarrTaskName;
use crate::models::stateful_list::StatefulList; use crate::models::stateful_list::StatefulList;
use crate::models::{Route, Scrollable};
use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::SonarrEvent;
#[cfg(test)] #[cfg(test)]
@@ -201,7 +201,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SystemDetailsHandler
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+2 -1
View File
@@ -9,6 +9,7 @@ mod tests {
use crate::handlers::KeyEventHandler; use crate::handlers::KeyEventHandler;
use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::table_handler::TableHandlingConfig;
use crate::handlers::table_handler::handle_table; use crate::handlers::table_handler::handle_table;
use crate::models::Route;
use crate::models::radarr_models::Movie; use crate::models::radarr_models::Movie;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::servarr_models::Language; use crate::models::servarr_models::Language;
@@ -98,7 +99,7 @@ mod tests {
self.app self.app
} }
fn current_route(&self) -> crate::models::Route { fn current_route(&self) -> Route {
self.app.get_current_route() self.app.get_current_route()
} }
} }
+24 -10
View File
@@ -3,12 +3,15 @@
extern crate assertables; extern crate assertables;
use anyhow::Result; use anyhow::Result;
use clap::{CommandFactory, Parser, crate_authors, crate_description, crate_name, crate_version}; use clap::{
Args, CommandFactory, Parser, crate_authors, crate_description, crate_name, crate_version,
};
use clap_complete::generate; use clap_complete::generate;
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{ use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
}; };
use indoc::indoc;
use log::{debug, error, warn}; use log::{debug, error, warn};
use network::NetworkTrait; use network::NetworkTrait;
use ratatui::Terminal; use ratatui::Terminal;
@@ -64,6 +67,13 @@ mod utils;
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Option<Command>, command: Option<Command>,
#[command(flatten)]
global: GlobalOpts,
}
#[derive(Args, Debug)]
#[command(next_help_heading = "Global Options")]
struct GlobalOpts {
#[arg( #[arg(
long, long,
global = true, global = true,
@@ -98,9 +108,12 @@ struct Cli {
#[arg( #[arg(
long, long,
global = true, global = true,
help = "For multi-instance configurations, you need to specify the name of the instance configuration that you want to use. help = indoc!{"
For multi-instance configurations, you need to specify the name of the instance configuration that you want to use.
This is useful when you have multiple instances of the same Servarr defined in your config file. This is useful when you have multiple instances of the same Servarr defined in your config file.
By default, if left empty, the first configured Servarr instance listed in the config file will be used." By default, if left empty, the first configured Servarr instance listed in the config file will be used.
"}
)] )]
servarr_name: Option<String>, servarr_name: Option<String>,
} }
@@ -114,13 +127,13 @@ async fn main() -> Result<()> {
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
let r = running.clone(); let r = running.clone();
let args = Cli::parse(); let args = Cli::parse();
let mut config = if let Some(ref config_file) = args.config_file { let mut config = if let Some(ref config_file) = args.global.config_file {
load_config(config_file.to_str().expect("Invalid config file specified"))? load_config(config_file.to_str().expect("Invalid config file specified"))?
} else { } else {
confy::load("managarr", "config")? confy::load("managarr", "config")?
}; };
let theme_name = config.theme.clone(); let theme_name = config.theme.clone();
let spinner_disabled = args.disable_spinner; let spinner_disabled = args.global.disable_spinner;
debug!("Managarr loaded using config: {config:?}"); debug!("Managarr loaded using config: {config:?}");
config.validate(); config.validate();
config.post_process_initialization(); config.post_process_initialization();
@@ -145,7 +158,7 @@ async fn main() -> Result<()> {
match args.command { match args.command {
Some(command) => match command { Some(command) => match command {
Command::Radarr(_) | Command::Sonarr(_) => { Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) => {
if spinner_disabled { if spinner_disabled {
start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await; start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await;
} else { } else {
@@ -165,8 +178,8 @@ async fn main() -> Result<()> {
}); });
start_ui( start_ui(
&app, &app,
&args.themes_file, &args.global.themes_file,
args.theme.unwrap_or(theme_name.unwrap_or_default()), args.global.theme.unwrap_or(theme_name.unwrap_or_default()),
) )
.await?; .await?;
} }
@@ -249,7 +262,7 @@ async fn start_ui(
terminal.draw(|f| ui(f, &mut app))?; terminal.draw(|f| ui(f, &mut app))?;
match input_events.next()? { match input_events.next()? {
InputEvent::KeyEvent(key) => { Some(InputEvent::KeyEvent(key)) => {
if key == Key::Char('q') && !app.ignore_special_keys_for_textbox_input { if key == Key::Char('q') && !app.ignore_special_keys_for_textbox_input {
break; break;
} }
@@ -257,7 +270,8 @@ async fn start_ui(
handlers::handle_events(key, &mut app); handlers::handle_events(key, &mut app);
} }
InputEvent::Tick => app.on_tick().await, Some(InputEvent::Tick) => app.on_tick().await,
_ => {}
} }
} }
+260
View File
@@ -0,0 +1,260 @@
use chrono::{DateTime, Utc};
use derivative::Derivative;
use enum_display_style_derive::EnumDisplayStyle;
use serde::{Deserialize, Serialize};
use serde_json::{Number, Value};
use strum::{Display, EnumIter};
use super::{
HorizontallyScrollableText, Serdeable,
servarr_models::{DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag},
};
use crate::serde_enum_from;
#[cfg(test)]
#[path = "lidarr_models_tests.rs"]
mod lidarr_models_tests;
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Artist {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub artist_name: HorizontallyScrollableText,
pub foreign_artist_id: String,
pub status: ArtistStatus,
pub overview: Option<String>,
pub artist_type: Option<String>,
pub disambiguation: Option<String>,
pub members: Option<Vec<Member>>,
pub path: String,
#[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<String>,
pub tags: Vec<Number>,
pub added: DateTime<Utc>,
pub ratings: Option<Ratings>,
pub statistics: Option<ArtistStatistics>,
}
#[derive(
Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, Display, EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum ArtistStatus {
#[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,
}
impl Eq for Ratings {}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Member {
pub name: Option<String>,
pub instrument: Option<String>,
}
impl Eq for Member {}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ArtistStatistics {
#[serde(deserialize_with = "super::from_i64")]
pub album_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub track_file_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub track_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub total_track_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub size_on_disk: i64,
#[serde(deserialize_with = "super::from_f64")]
pub percent_of_tracks: f64,
}
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,
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 Albums")]
All,
#[display_style(name = "No New Albums")]
None,
#[display_style(name = "New Albums")]
New,
}
#[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 album_id: Option<Number>,
pub artist_id: Option<Number>,
#[serde(deserialize_with = "super::from_f64")]
pub size: f64,
#[serde(deserialize_with = "super::from_f64")]
pub sizeleft: f64,
pub output_path: Option<HorizontallyScrollableText>,
#[serde(default)]
pub indexer: String,
pub download_client: Option<String>,
}
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<DownloadRecord>,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SystemStatus {
pub version: String,
pub start_time: DateTime<Utc>,
}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AddArtistSearchResult {
pub foreign_artist_id: String,
pub artist_name: HorizontallyScrollableText,
pub status: ArtistStatus,
pub overview: Option<String>,
pub artist_type: Option<String>,
pub disambiguation: Option<String>,
pub genres: Vec<String>,
pub ratings: Option<Ratings>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub struct DeleteArtistParams {
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 EditArtistParams {
pub artist_id: i64,
pub monitored: Option<bool>,
pub monitor_new_items: Option<NewItemMonitorType>,
pub quality_profile_id: Option<i64>,
pub metadata_profile_id: Option<i64>,
pub root_folder_path: Option<String>,
pub tags: Option<Vec<i64>>,
#[serde(skip_serializing, skip_deserializing)]
pub tag_input_string: Option<String>,
pub clear_tags: bool,
}
impl From<LidarrSerdeable> for Serdeable {
fn from(value: LidarrSerdeable) -> Serdeable {
Serdeable::Lidarr(value)
}
}
serde_enum_from!(
LidarrSerdeable {
AddArtistSearchResults(Vec<AddArtistSearchResult>),
Artist(Artist),
Artists(Vec<Artist>),
DiskSpaces(Vec<DiskSpace>),
DownloadsResponse(DownloadsResponse),
HostConfig(HostConfig),
MetadataProfiles(Vec<MetadataProfile>),
QualityProfiles(Vec<QualityProfile>),
RootFolders(Vec<RootFolder>),
SecurityConfig(SecurityConfig),
SystemStatus(SystemStatus),
Tag(Tag),
Tags(Vec<Tag>),
Value(Value),
}
);
+495
View File
@@ -0,0 +1,495 @@
#[cfg(test)]
mod tests {
use chrono::Utc;
use pretty_assertions::{assert_eq, assert_str_eq};
use serde_json::json;
use crate::models::lidarr_models::{
AddArtistSearchResult, DownloadRecord, DownloadStatus, DownloadsResponse, Member,
MetadataProfile, NewItemMonitorType, SystemStatus,
};
use crate::models::servarr_models::{
DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag,
};
use crate::models::{
Serdeable,
lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings},
};
#[test]
fn test_artist_status_default() {
assert_eq!(ArtistStatus::default(), ArtistStatus::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 Albums");
assert_str_eq!(NewItemMonitorType::None.to_display_str(), "No New Albums");
assert_str_eq!(NewItemMonitorType::New.to_display_str(), "New Albums");
}
#[test]
fn test_lidarr_serdeable_from() {
let lidarr_serdeable = LidarrSerdeable::Value(json!({}));
let serdeable: Serdeable = Serdeable::from(lidarr_serdeable.clone());
assert_eq!(serdeable, Serdeable::Lidarr(lidarr_serdeable));
}
#[test]
fn test_lidarr_serdeable_from_unit() {
let lidarr_serdeable = LidarrSerdeable::from(());
assert_eq!(lidarr_serdeable, LidarrSerdeable::Value(json!({})));
}
#[test]
fn test_lidarr_serdeable_from_value() {
let value = json!({"test": "test"});
let lidarr_serdeable: LidarrSerdeable = value.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::Value(value));
}
#[test]
fn test_lidarr_serdeable_from_artists() {
let artists = vec![Artist {
id: 1,
..Artist::default()
}];
let lidarr_serdeable: LidarrSerdeable = artists.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::Artists(artists));
}
#[test]
fn test_artist_deserialization() {
let artist_json = json!({
"id": 1,
"artistName": "Test Artist",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "Test overview",
"artistType": "Group",
"disambiguation": "UK Band",
"path": "/music/test-artist",
"members": [
{ "name": "alex", "instrument": "piano" },
{ "name": "madi", "instrument": "vocals" }
],
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": ["Rock", "Alternative"],
"tags": [1, 2],
"added": "2023-01-01T00:00:00Z",
"ratings": {
"votes": 100,
"value": 4.5
},
"statistics": {
"albumCount": 5,
"trackFileCount": 50,
"trackCount": 60,
"totalTrackCount": 70,
"sizeOnDisk": 1000000000,
"percentOfTracks": 83.33
}
});
let expected_members_vec = vec![
Member {
name: Some("alex".to_string()),
instrument: Some("piano".to_string()),
},
Member {
name: Some("madi".to_string()),
instrument: Some("vocals".to_string()),
},
];
let artist: Artist = serde_json::from_value(artist_json).unwrap();
assert_eq!(artist.id, 1);
assert_str_eq!(artist.artist_name.text, "Test Artist");
assert_str_eq!(artist.foreign_artist_id, "test-foreign-id");
assert_eq!(artist.status, ArtistStatus::Continuing);
assert_some_eq_x!(&artist.overview, "Test overview");
assert_some_eq_x!(&artist.artist_type, "Group");
assert_some_eq_x!(&artist.disambiguation, "UK Band");
assert_str_eq!(artist.path, "/music/test-artist");
assert_some_eq_x!(&artist.members, &expected_members_vec);
assert_eq!(artist.quality_profile_id, 1);
assert_eq!(artist.metadata_profile_id, 1);
assert!(artist.monitored);
assert_eq!(artist.monitor_new_items, NewItemMonitorType::All);
assert_eq!(artist.genres, vec!["Rock", "Alternative"]);
assert_eq!(artist.tags.len(), 2);
assert_some!(&artist.ratings);
assert_some!(&artist.statistics);
let ratings = artist.ratings.unwrap();
assert_eq!(ratings.votes, 100);
assert_eq!(ratings.value, 4.5);
let stats = artist.statistics.unwrap();
assert_eq!(stats.album_count, 5);
assert_eq!(stats.track_file_count, 50);
assert_eq!(stats.track_count, 60);
assert_eq!(stats.total_track_count, 70);
assert_eq!(stats.size_on_disk, 1000000000);
assert_eq!(stats.percent_of_tracks, 83.33);
}
#[test]
fn test_artist_status_deserialization() {
assert_eq!(
serde_json::from_str::<ArtistStatus>("\"continuing\"").unwrap(),
ArtistStatus::Continuing
);
assert_eq!(
serde_json::from_str::<ArtistStatus>("\"ended\"").unwrap(),
ArtistStatus::Ended
);
assert_eq!(
serde_json::from_str::<ArtistStatus>("\"deleted\"").unwrap(),
ArtistStatus::Deleted
);
}
#[test]
fn test_ratings_equality() {
let ratings1 = Ratings {
votes: 100,
value: 4.5,
};
let ratings2 = Ratings {
votes: 100,
value: 4.5,
};
let ratings3 = Ratings {
votes: 50,
value: 3.0,
};
assert_eq!(ratings1, ratings2);
assert_ne!(ratings1, ratings3);
}
#[test]
fn test_artist_statistics_equality() {
let stats1 = ArtistStatistics {
album_count: 5,
track_file_count: 50,
track_count: 60,
total_track_count: 70,
size_on_disk: 1000000000,
percent_of_tracks: 83.33,
};
let stats2 = ArtistStatistics {
album_count: 5,
track_file_count: 50,
track_count: 60,
total_track_count: 70,
size_on_disk: 1000000000,
percent_of_tracks: 83.33,
};
let stats3 = ArtistStatistics::default();
assert_eq!(stats1, stats2);
assert_ne!(stats1, stats3);
}
#[test]
fn test_artist_with_optional_fields_none() {
let artist_json = json!({
"id": 1,
"artistName": "Test Artist",
"foreignArtistId": "",
"status": "continuing",
"path": "",
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": false,
"monitorNewItems": "all",
"genres": [],
"tags": [],
"added": "2023-01-01T00:00:00Z"
});
let artist: Artist = serde_json::from_value(artist_json).unwrap();
assert_none!(&artist.overview);
assert_none!(&artist.artist_type);
assert_none!(&artist.disambiguation);
assert_eq!(artist.monitor_new_items, NewItemMonitorType::All);
assert_none!(&artist.ratings);
assert_none!(&artist.statistics);
}
#[test]
fn test_lidarr_serdeable_from_artist() {
let artist = Artist {
id: 1,
..Artist::default()
};
let lidarr_serdeable: LidarrSerdeable = artist.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::Artist(artist));
}
#[test]
fn test_lidarr_serdeable_from_disk_spaces() {
let disk_spaces = vec![DiskSpace {
free_space: 1,
total_space: 1,
}];
let lidarr_serdeable: LidarrSerdeable = disk_spaces.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::DiskSpaces(disk_spaces));
}
#[test]
fn test_lidarr_serdeable_from_downloads_response() {
let downloads_response = DownloadsResponse {
records: vec![DownloadRecord {
id: 1,
..DownloadRecord::default()
}],
};
let lidarr_serdeable: LidarrSerdeable = downloads_response.clone().into();
assert_eq!(
lidarr_serdeable,
LidarrSerdeable::DownloadsResponse(downloads_response)
);
}
#[test]
fn test_lidarr_serdeable_from_metadata_profiles() {
let metadata_profiles = vec![MetadataProfile {
id: 1,
name: "Standard".to_owned(),
}];
let lidarr_serdeable: LidarrSerdeable = metadata_profiles.clone().into();
assert_eq!(
lidarr_serdeable,
LidarrSerdeable::MetadataProfiles(metadata_profiles)
);
}
#[test]
fn test_lidarr_serdeable_from_host_config() {
let host_config = HostConfig {
port: 8686,
..HostConfig::default()
};
let lidarr_serdeable: LidarrSerdeable = host_config.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::HostConfig(host_config));
}
#[test]
fn test_lidarr_serdeable_from_quality_profiles() {
let quality_profiles = vec![QualityProfile {
id: 1,
name: "Any".to_owned(),
}];
let lidarr_serdeable: LidarrSerdeable = quality_profiles.clone().into();
assert_eq!(
lidarr_serdeable,
LidarrSerdeable::QualityProfiles(quality_profiles)
);
}
#[test]
fn test_lidarr_serdeable_from_root_folders() {
let root_folders = vec![RootFolder {
id: 1,
path: "/music".to_owned(),
accessible: true,
free_space: 1000000,
unmapped_folders: None,
}];
let lidarr_serdeable: LidarrSerdeable = root_folders.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::RootFolders(root_folders));
}
#[test]
fn test_lidarr_serdeable_from_security_config() {
let security_config = SecurityConfig {
api_key: "test-key".to_owned(),
..SecurityConfig::default()
};
let lidarr_serdeable: LidarrSerdeable = security_config.clone().into();
assert_eq!(
lidarr_serdeable,
LidarrSerdeable::SecurityConfig(security_config)
);
}
#[test]
fn test_lidarr_serdeable_from_system_status() {
let system_status = SystemStatus {
version: "1.0.0".to_owned(),
start_time: Utc::now(),
};
let lidarr_serdeable: LidarrSerdeable = system_status.clone().into();
assert_eq!(
lidarr_serdeable,
LidarrSerdeable::SystemStatus(system_status)
);
}
#[test]
fn test_lidarr_serdeable_from_tags() {
let tags = vec![Tag {
id: 1,
label: "rock".to_owned(),
}];
let lidarr_serdeable: LidarrSerdeable = tags.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::Tags(tags));
}
#[test]
fn test_artist_status_display() {
assert_str_eq!(ArtistStatus::Continuing.to_string(), "continuing");
assert_str_eq!(ArtistStatus::Ended.to_string(), "ended");
assert_str_eq!(ArtistStatus::Deleted.to_string(), "deleted");
}
#[test]
fn test_artist_status_to_display_str() {
assert_str_eq!(ArtistStatus::Continuing.to_display_str(), "Continuing");
assert_str_eq!(ArtistStatus::Ended.to_display_str(), "Ended");
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_add_artist_search_result_deserialization() {
let search_result_json = json!({
"foreignArtistId": "test-foreign-id",
"artistName": "Test Artist",
"status": "continuing",
"overview": "Test overview",
"artistType": "Group",
"disambiguation": "UK Band",
"genres": ["Rock", "Alternative"],
"ratings": {
"votes": 100,
"value": 4.5
}
});
let search_result: AddArtistSearchResult = serde_json::from_value(search_result_json).unwrap();
assert_str_eq!(search_result.foreign_artist_id, "test-foreign-id");
assert_str_eq!(search_result.artist_name.text, "Test Artist");
assert_eq!(search_result.status, ArtistStatus::Continuing);
assert_some_eq_x!(&search_result.overview, "Test overview");
assert_some_eq_x!(&search_result.artist_type, "Group");
assert_some_eq_x!(&search_result.disambiguation, "UK Band");
assert_eq!(search_result.genres, vec!["Rock", "Alternative"]);
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_artist_search_result_with_optional_fields_none() {
let search_result_json = json!({
"foreignArtistId": "test-foreign-id",
"artistName": "Test Artist",
"status": "ended",
"genres": []
});
let search_result: AddArtistSearchResult = serde_json::from_value(search_result_json).unwrap();
assert_str_eq!(search_result.foreign_artist_id, "test-foreign-id");
assert_str_eq!(search_result.artist_name.text, "Test Artist");
assert_eq!(search_result.status, ArtistStatus::Ended);
assert_none!(&search_result.overview);
assert_none!(&search_result.artist_type);
assert_none!(&search_result.disambiguation);
assert!(search_result.genres.is_empty());
assert_none!(&search_result.ratings);
}
#[test]
fn test_lidarr_serdeable_from_add_artist_search_results() {
let search_results = vec![AddArtistSearchResult {
foreign_artist_id: "test-id".to_owned(),
..AddArtistSearchResult::default()
}];
let lidarr_serdeable: LidarrSerdeable = search_results.clone().into();
assert_eq!(
lidarr_serdeable,
LidarrSerdeable::AddArtistSearchResults(search_results)
);
}
}
+6 -3
View File
@@ -3,7 +3,9 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use crate::app::ServarrConfig; use crate::app::ServarrConfig;
use crate::app::context_clues::ContextClue; 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::radarr::radarr_data::ActiveRadarrBlock;
use lidarr_models::LidarrSerdeable;
use radarr_models::RadarrSerdeable; use radarr_models::RadarrSerdeable;
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
@@ -11,6 +13,7 @@ use serde_json::Number;
use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use sonarr_models::SonarrSerdeable; use sonarr_models::SonarrSerdeable;
pub mod lidarr_models;
pub mod radarr_models; pub mod radarr_models;
pub mod servarr_data; pub mod servarr_data;
pub mod servarr_models; pub mod servarr_models;
@@ -30,7 +33,7 @@ pub enum Route {
Radarr(ActiveRadarrBlock, Option<ActiveRadarrBlock>), Radarr(ActiveRadarrBlock, Option<ActiveRadarrBlock>),
Sonarr(ActiveSonarrBlock, Option<ActiveSonarrBlock>), Sonarr(ActiveSonarrBlock, Option<ActiveSonarrBlock>),
Readarr, Readarr,
Lidarr, Lidarr(ActiveLidarrBlock, Option<ActiveLidarrBlock>),
Whisparr, Whisparr,
Bazarr, Bazarr,
Prowlarr, Prowlarr,
@@ -43,6 +46,7 @@ pub enum Route {
pub enum Serdeable { pub enum Serdeable {
Radarr(RadarrSerdeable), Radarr(RadarrSerdeable),
Sonarr(SonarrSerdeable), Sonarr(SonarrSerdeable),
Lidarr(LidarrSerdeable),
} }
pub trait Scrollable { pub trait Scrollable {
@@ -289,8 +293,7 @@ impl TabState {
TabState { tabs, index: 0 } TabState { tabs, index: 0 }
} }
// Allowing this code for now since we'll eventually be implementing additional Servarr support, and we'll need it then #[cfg(test)]
#[allow(dead_code)]
pub fn set_index(&mut self, index: usize) -> &TabRoute { pub fn set_index(&mut self, index: usize) -> &TabRoute {
self.index = index; self.index = index;
&self.tabs[self.index] &self.tabs[self.index]
@@ -0,0 +1,250 @@
use serde_json::Number;
use super::modals::EditArtistModal;
use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES;
use crate::models::{
BlockSelectionState, HorizontallyScrollableText, Route, TabRoute, TabState,
lidarr_models::{AddArtistSearchResult, Artist, DownloadRecord},
servarr_models::{DiskSpace, RootFolder},
stateful_table::StatefulTable,
};
use crate::network::lidarr_network::LidarrEvent;
use bimap::BiMap;
use chrono::{DateTime, Utc};
use strum::EnumIter;
#[cfg(test)]
use {
crate::models::lidarr_models::NewItemMonitorType,
crate::models::stateful_table::SortOption,
crate::network::lidarr_network::lidarr_network_test_utils::test_utils::quality_profile_map,
crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
add_artist_search_result, download_record, metadata_profile, metadata_profile_map,
quality_profile, root_folder, tags_map,
},
crate::network::servarr_test_utils::diskspace,
strum::{Display, EnumString, IntoEnumIterator},
};
#[cfg(test)]
#[path = "lidarr_data_tests.rs"]
mod lidarr_data_tests;
pub struct LidarrData<'a> {
pub add_artist_search: Option<HorizontallyScrollableText>,
pub add_import_list_exclusion: bool,
pub add_searched_artists: Option<StatefulTable<AddArtistSearchResult>>,
pub artists: StatefulTable<Artist>,
pub delete_artist_files: bool,
pub disk_space_vec: Vec<DiskSpace>,
pub downloads: StatefulTable<DownloadRecord>,
pub edit_artist_modal: Option<EditArtistModal>,
pub main_tabs: TabState,
pub metadata_profile_map: BiMap<i64, String>,
pub prompt_confirm: bool,
pub prompt_confirm_action: Option<LidarrEvent>,
pub quality_profile_map: BiMap<i64, String>,
pub root_folders: StatefulTable<RootFolder>,
pub selected_block: BlockSelectionState<'a, ActiveLidarrBlock>,
pub start_time: DateTime<Utc>,
pub tags_map: BiMap<i64, String>,
pub version: String,
}
impl LidarrData<'_> {
pub fn reset_delete_artist_preferences(&mut self) {
self.delete_artist_files = false;
self.add_import_list_exclusion = false;
}
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::<Vec<String>>()
.join(", ")
}
pub fn sorted_quality_profile_names(&self) -> Vec<String> {
let mut quality_profile_names: Vec<String> =
self.quality_profile_map.right_values().cloned().collect();
quality_profile_names.sort();
quality_profile_names
}
pub fn sorted_metadata_profile_names(&self) -> Vec<String> {
let mut metadata_profile_names: Vec<String> =
self.metadata_profile_map.right_values().cloned().collect();
metadata_profile_names.sort();
metadata_profile_names
}
}
impl<'a> Default for LidarrData<'a> {
fn default() -> LidarrData<'a> {
LidarrData {
add_artist_search: None,
add_import_list_exclusion: false,
add_searched_artists: None,
artists: StatefulTable::default(),
delete_artist_files: false,
disk_space_vec: Vec::new(),
downloads: StatefulTable::default(),
edit_artist_modal: None,
metadata_profile_map: BiMap::new(),
prompt_confirm: false,
prompt_confirm_action: None,
quality_profile_map: BiMap::new(),
root_folders: StatefulTable::default(),
selected_block: BlockSelectionState::default(),
start_time: DateTime::default(),
tags_map: BiMap::new(),
version: String::new(),
main_tabs: TabState::new(vec![TabRoute {
title: "Library".to_string(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: Some(&ARTISTS_CONTEXT_CLUES),
config: None,
}]),
}
}
}
#[cfg(test)]
impl LidarrData<'_> {
pub fn test_default_fully_populated() -> Self {
let mut edit_artist_modal = EditArtistModal {
monitored: Some(true),
path: "/nfs/music".into(),
tags: "alex".into(),
..EditArtistModal::default()
};
edit_artist_modal
.monitor_list
.set_items(NewItemMonitorType::iter().collect());
edit_artist_modal
.quality_profile_list
.set_items(vec![quality_profile().name]);
edit_artist_modal
.metadata_profile_list
.set_items(vec![metadata_profile().name]);
let mut lidarr_data = LidarrData {
delete_artist_files: true,
disk_space_vec: vec![diskspace()],
quality_profile_map: quality_profile_map(),
metadata_profile_map: metadata_profile_map(),
edit_artist_modal: Some(edit_artist_modal),
tags_map: tags_map(),
..LidarrData::default()
};
lidarr_data.artists.set_items(vec![Artist::default()]);
lidarr_data.artists.sorting(vec![SortOption {
name: "Name",
cmp_fn: Some(|a: &Artist, b: &Artist| a.artist_name.text.cmp(&b.artist_name.text)),
}]);
lidarr_data.artists.search = Some("artist search".into());
lidarr_data.artists.filter = Some("artist filter".into());
lidarr_data.downloads.set_items(vec![download_record()]);
lidarr_data.root_folders.set_items(vec![root_folder()]);
lidarr_data.version = "1.0.0".to_owned();
lidarr_data.add_artist_search = Some("Test Artist".into());
let mut add_searched_artists = StatefulTable::default();
add_searched_artists.set_items(vec![add_artist_search_result()]);
lidarr_data.add_searched_artists = Some(add_searched_artists);
lidarr_data
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)]
#[cfg_attr(test, derive(Display, EnumString))]
pub enum ActiveLidarrBlock {
#[default]
Artists,
ArtistsSortPrompt,
AddArtistEmptySearchResults,
AddArtistSearchInput,
AddArtistSearchResults,
DeleteArtistPrompt,
DeleteArtistConfirmPrompt,
DeleteArtistToggleDeleteFile,
DeleteArtistToggleAddListExclusion,
EditArtistPrompt,
EditArtistConfirmPrompt,
EditArtistPathInput,
EditArtistSelectMetadataProfile,
EditArtistSelectMonitorNewItems,
EditArtistSelectQualityProfile,
EditArtistTagsInput,
EditArtistToggleMonitored,
FilterArtists,
FilterArtistsError,
SearchArtists,
SearchArtistsError,
UpdateAllArtistsPrompt,
}
pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 7] = [
ActiveLidarrBlock::Artists,
ActiveLidarrBlock::ArtistsSortPrompt,
ActiveLidarrBlock::FilterArtists,
ActiveLidarrBlock::FilterArtistsError,
ActiveLidarrBlock::SearchArtists,
ActiveLidarrBlock::SearchArtistsError,
ActiveLidarrBlock::UpdateAllArtistsPrompt,
];
pub static ADD_ARTIST_BLOCKS: [ActiveLidarrBlock; 3] = [
ActiveLidarrBlock::AddArtistEmptySearchResults,
ActiveLidarrBlock::AddArtistSearchInput,
ActiveLidarrBlock::AddArtistSearchResults,
];
pub static DELETE_ARTIST_BLOCKS: [ActiveLidarrBlock; 4] = [
ActiveLidarrBlock::DeleteArtistPrompt,
ActiveLidarrBlock::DeleteArtistConfirmPrompt,
ActiveLidarrBlock::DeleteArtistToggleDeleteFile,
ActiveLidarrBlock::DeleteArtistToggleAddListExclusion,
];
pub const DELETE_ARTIST_SELECTION_BLOCKS: &[&[ActiveLidarrBlock]] = &[
&[ActiveLidarrBlock::DeleteArtistToggleDeleteFile],
&[ActiveLidarrBlock::DeleteArtistToggleAddListExclusion],
&[ActiveLidarrBlock::DeleteArtistConfirmPrompt],
];
pub static EDIT_ARTIST_BLOCKS: [ActiveLidarrBlock; 8] = [
ActiveLidarrBlock::EditArtistPrompt,
ActiveLidarrBlock::EditArtistConfirmPrompt,
ActiveLidarrBlock::EditArtistPathInput,
ActiveLidarrBlock::EditArtistSelectMetadataProfile,
ActiveLidarrBlock::EditArtistSelectMonitorNewItems,
ActiveLidarrBlock::EditArtistSelectQualityProfile,
ActiveLidarrBlock::EditArtistTagsInput,
ActiveLidarrBlock::EditArtistToggleMonitored,
];
pub const EDIT_ARTIST_SELECTION_BLOCKS: &[&[ActiveLidarrBlock]] = &[
&[ActiveLidarrBlock::EditArtistToggleMonitored],
&[ActiveLidarrBlock::EditArtistSelectMonitorNewItems],
&[ActiveLidarrBlock::EditArtistSelectQualityProfile],
&[ActiveLidarrBlock::EditArtistSelectMetadataProfile],
&[ActiveLidarrBlock::EditArtistPathInput],
&[ActiveLidarrBlock::EditArtistTagsInput],
&[ActiveLidarrBlock::EditArtistConfirmPrompt],
];
impl From<ActiveLidarrBlock> for Route {
fn from(active_lidarr_block: ActiveLidarrBlock) -> Route {
Route::Lidarr(active_lidarr_block, None)
}
}
impl From<(ActiveLidarrBlock, Option<ActiveLidarrBlock>)> for Route {
fn from(value: (ActiveLidarrBlock, Option<ActiveLidarrBlock>)) -> Route {
Route::Lidarr(value.0, value.1)
}
}
@@ -0,0 +1,239 @@
#[cfg(test)]
mod tests {
use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES;
use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS,
EDIT_ARTIST_SELECTION_BLOCKS,
};
use crate::models::{
BlockSelectionState, Route,
servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS, LidarrData},
};
use bimap::BiMap;
use chrono::{DateTime, Utc};
use pretty_assertions::{assert_eq, assert_str_eq};
use serde_json::Number;
#[test]
fn test_from_active_lidarr_block_to_route() {
assert_eq!(
Route::from(ActiveLidarrBlock::Artists),
Route::Lidarr(ActiveLidarrBlock::Artists, None)
);
}
#[test]
fn test_from_tuple_to_route_with_context() {
assert_eq!(
Route::from((ActiveLidarrBlock::Artists, Some(ActiveLidarrBlock::Artists))),
Route::Lidarr(ActiveLidarrBlock::Artists, Some(ActiveLidarrBlock::Artists),)
);
}
#[test]
fn test_reset_delete_artist_preferences() {
let mut lidarr_data = LidarrData {
delete_artist_files: true,
add_import_list_exclusion: true,
..LidarrData::default()
};
lidarr_data.reset_delete_artist_preferences();
assert!(!lidarr_data.delete_artist_files);
assert!(!lidarr_data.add_import_list_exclusion);
}
#[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 lidarr_data = LidarrData {
tags_map,
..LidarrData::default()
};
assert_str_eq!(
lidarr_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 3".to_owned());
quality_profile_map.insert(2, "test 2".to_owned());
quality_profile_map.insert(1, "test 1".to_owned());
let lidarr_data = LidarrData {
quality_profile_map,
..LidarrData::default()
};
let expected_quality_profile_vec = vec![
"test 1".to_owned(),
"test 2".to_owned(),
"test 3".to_owned(),
];
assert_iter_eq!(
lidarr_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 3".to_owned());
metadata_profile_map.insert(2, "test 2".to_owned());
metadata_profile_map.insert(1, "test 1".to_owned());
let lidarr_data = LidarrData {
metadata_profile_map,
..LidarrData::default()
};
let expected_metadata_profile_vec = vec![
"test 1".to_owned(),
"test 2".to_owned(),
"test 3".to_owned(),
];
assert_iter_eq!(
lidarr_data.sorted_metadata_profile_names(),
expected_metadata_profile_vec
);
}
#[test]
fn test_lidarr_data_default() {
let lidarr_data = LidarrData::default();
assert_none!(lidarr_data.add_artist_search);
assert!(!lidarr_data.add_import_list_exclusion);
assert_none!(lidarr_data.add_searched_artists);
assert_is_empty!(lidarr_data.artists);
assert!(!lidarr_data.delete_artist_files);
assert_is_empty!(lidarr_data.disk_space_vec);
assert_is_empty!(lidarr_data.downloads);
assert_none!(lidarr_data.edit_artist_modal);
assert_is_empty!(lidarr_data.metadata_profile_map);
assert!(!lidarr_data.prompt_confirm);
assert_none!(lidarr_data.prompt_confirm_action);
assert_is_empty!(lidarr_data.quality_profile_map);
assert_is_empty!(lidarr_data.root_folders);
assert_eq!(lidarr_data.selected_block, BlockSelectionState::default());
assert_eq!(lidarr_data.start_time, <DateTime<Utc>>::default());
assert_is_empty!(lidarr_data.tags_map);
assert_is_empty!(lidarr_data.version);
assert_eq!(lidarr_data.main_tabs.tabs.len(), 1);
assert_str_eq!(lidarr_data.main_tabs.tabs[0].title, "Library");
assert_eq!(
lidarr_data.main_tabs.tabs[0].route,
ActiveLidarrBlock::Artists.into()
);
assert_some_eq_x!(
&lidarr_data.main_tabs.tabs[0].contextual_help,
&ARTISTS_CONTEXT_CLUES
);
assert_none!(lidarr_data.main_tabs.tabs[0].config);
}
#[test]
fn test_library_blocks_contains_expected_blocks() {
assert_eq!(LIBRARY_BLOCKS.len(), 7);
assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::Artists));
assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::ArtistsSortPrompt));
assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::SearchArtists));
assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::SearchArtistsError));
assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::FilterArtists));
assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::FilterArtistsError));
assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::UpdateAllArtistsPrompt));
}
#[test]
fn test_add_artist_blocks_contents() {
assert_eq!(ADD_ARTIST_BLOCKS.len(), 3);
assert!(ADD_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::AddArtistEmptySearchResults));
assert!(ADD_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::AddArtistSearchInput));
assert!(ADD_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::AddArtistSearchResults));
}
#[test]
fn test_delete_artist_blocks_contents() {
assert_eq!(DELETE_ARTIST_BLOCKS.len(), 4);
assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistPrompt));
assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistConfirmPrompt));
assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistToggleDeleteFile));
assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistToggleAddListExclusion));
}
#[test]
fn test_delete_artist_selection_blocks_ordering() {
let mut delete_artist_block_iter = DELETE_ARTIST_SELECTION_BLOCKS.iter();
assert_eq!(
delete_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::DeleteArtistToggleDeleteFile]
);
assert_eq!(
delete_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::DeleteArtistToggleAddListExclusion]
);
assert_eq!(
delete_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::DeleteArtistConfirmPrompt]
);
assert_none!(delete_artist_block_iter.next());
}
#[test]
fn test_edit_artist_blocks() {
assert_eq!(EDIT_ARTIST_BLOCKS.len(), 8);
assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistPrompt));
assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistConfirmPrompt));
assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistPathInput));
assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistSelectMetadataProfile));
assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistSelectMonitorNewItems));
assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistSelectQualityProfile));
assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistTagsInput));
assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistToggleMonitored));
}
#[test]
fn test_edit_artist_selection_blocks_ordering() {
let mut edit_artist_block_iter = EDIT_ARTIST_SELECTION_BLOCKS.iter();
assert_eq!(
edit_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::EditArtistToggleMonitored]
);
assert_eq!(
edit_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::EditArtistSelectMonitorNewItems]
);
assert_eq!(
edit_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::EditArtistSelectQualityProfile]
);
assert_eq!(
edit_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::EditArtistSelectMetadataProfile]
);
assert_eq!(
edit_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::EditArtistPathInput]
);
assert_eq!(
edit_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::EditArtistTagsInput]
);
assert_eq!(
edit_artist_block_iter.next().unwrap(),
&[ActiveLidarrBlock::EditArtistConfirmPrompt]
);
assert_none!(edit_artist_block_iter.next());
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod lidarr_data;
pub mod modals;
+78
View File
@@ -0,0 +1,78 @@
use strum::IntoEnumIterator;
use super::lidarr_data::LidarrData;
use crate::models::{
HorizontallyScrollableText, lidarr_models::NewItemMonitorType, stateful_list::StatefulList,
};
#[cfg(test)]
#[path = "modals_tests.rs"]
mod modals_tests;
#[derive(Default)]
#[cfg_attr(test, derive(Debug))]
pub struct EditArtistModal {
pub monitor_list: StatefulList<NewItemMonitorType>,
pub quality_profile_list: StatefulList<String>,
pub metadata_profile_list: StatefulList<String>,
pub monitored: Option<bool>,
pub path: HorizontallyScrollableText,
pub tags: HorizontallyScrollableText,
}
impl From<&LidarrData<'_>> for EditArtistModal {
fn from(lidarr_data: &LidarrData<'_>) -> EditArtistModal {
let mut edit_artist_modal = EditArtistModal::default();
let artist = lidarr_data.artists.current_selection();
edit_artist_modal
.monitor_list
.set_items(Vec::from_iter(NewItemMonitorType::iter()));
edit_artist_modal.path = artist.path.clone().into();
edit_artist_modal.tags = lidarr_data.tag_ids_to_display(&artist.tags).into();
edit_artist_modal.monitored = Some(artist.monitored);
let monitor_index = edit_artist_modal
.monitor_list
.items
.iter()
.position(|m| *m == artist.monitor_new_items);
edit_artist_modal.monitor_list.state.select(monitor_index);
edit_artist_modal
.quality_profile_list
.set_items(lidarr_data.sorted_quality_profile_names());
let quality_profile_name = lidarr_data
.quality_profile_map
.get_by_left(&artist.quality_profile_id)
.unwrap();
let quality_profile_index = edit_artist_modal
.quality_profile_list
.items
.iter()
.position(|profile| profile == quality_profile_name);
edit_artist_modal
.quality_profile_list
.state
.select(quality_profile_index);
edit_artist_modal
.metadata_profile_list
.set_items(lidarr_data.sorted_metadata_profile_names());
let metadata_profile_name = lidarr_data
.metadata_profile_map
.get_by_left(&artist.metadata_profile_id)
.unwrap();
let metadata_profile_index = edit_artist_modal
.metadata_profile_list
.items
.iter()
.position(|profile| profile == metadata_profile_name);
edit_artist_modal
.metadata_profile_list
.state
.select(metadata_profile_index);
edit_artist_modal
}
}
@@ -0,0 +1,54 @@
#[cfg(test)]
mod tests {
use bimap::BiMap;
use pretty_assertions::{assert_eq, assert_str_eq};
use crate::models::lidarr_models::{Artist, NewItemMonitorType};
use crate::models::servarr_data::lidarr::lidarr_data::LidarrData;
use crate::models::servarr_data::lidarr::modals::EditArtistModal;
#[test]
fn test_edit_artist_modal_from_lidarr_data() {
let mut lidarr_data = LidarrData {
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())]),
..LidarrData::default()
};
let artist = Artist {
id: 1,
monitored: true,
monitor_new_items: NewItemMonitorType::All,
quality_profile_id: 1,
metadata_profile_id: 1,
path: "/nfs/music/test_artist".to_owned(),
tags: vec![serde_json::Number::from(1)],
..Artist::default()
};
lidarr_data.artists.set_items(vec![artist]);
let edit_artist_modal = EditArtistModal::from(&lidarr_data);
assert_eq!(edit_artist_modal.monitored, Some(true));
assert_eq!(
*edit_artist_modal.monitor_list.current_selection(),
NewItemMonitorType::All
);
assert_str_eq!(
edit_artist_modal.quality_profile_list.current_selection(),
"HD - 1080p"
);
assert_str_eq!(
edit_artist_modal.metadata_profile_list.current_selection(),
"Standard"
);
assert_str_eq!(edit_artist_modal.path.text, "/nfs/music/test_artist");
assert_str_eq!(edit_artist_modal.tags.text, "usenet");
}
}
+1
View File
@@ -1,5 +1,6 @@
use crate::models::Route; use crate::models::Route;
pub mod lidarr;
pub mod modals; pub mod modals;
pub mod radarr; pub mod radarr;
pub mod sonarr; pub mod sonarr;
@@ -1,9 +1,6 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
mod radarr_data_tests { mod radarr_data_tests {
use chrono::{DateTime, Utc};
use pretty_assertions::{assert_eq, assert_str_eq};
use crate::app::context_clues::{ use crate::app::context_clues::{
BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
@@ -12,6 +9,10 @@ mod tests {
COLLECTIONS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES,
MOVIE_DETAILS_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES,
}; };
use bimap::BiMap;
use chrono::{DateTime, Utc};
use pretty_assertions::{assert_eq, assert_str_eq};
use serde_json::Number;
use crate::models::Route; use crate::models::Route;
use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils;
@@ -61,6 +62,45 @@ mod tests {
assert_movie_info_tabs_reset!(radarr_data); assert_movie_info_tabs_reset!(radarr_data);
} }
#[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 radarr_data = RadarrData {
tags_map,
..RadarrData::default()
};
assert_str_eq!(
radarr_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 3".to_owned());
quality_profile_map.insert(2, "test 2".to_owned());
quality_profile_map.insert(1, "test 1".to_owned());
let radarr_data = RadarrData {
quality_profile_map,
..RadarrData::default()
};
let expected_quality_profile_vec = vec![
"test 1".to_owned(),
"test 2".to_owned(),
"test 3".to_owned(),
];
assert_iter_eq!(
radarr_data.sorted_quality_profile_names(),
expected_quality_profile_vec
);
}
#[test] #[test]
fn test_radarr_data_defaults() { fn test_radarr_data_defaults() {
let radarr_data = RadarrData::default(); let radarr_data = RadarrData::default();
+3
View File
@@ -23,6 +23,7 @@ use crate::{
mod modals_tests; mod modals_tests;
#[derive(Default)] #[derive(Default)]
#[cfg_attr(test, derive(Debug))]
pub struct AddSeriesModal { pub struct AddSeriesModal {
pub root_folder_list: StatefulList<RootFolder>, pub root_folder_list: StatefulList<RootFolder>,
pub monitor_list: StatefulList<SeriesMonitor>, pub monitor_list: StatefulList<SeriesMonitor>,
@@ -130,6 +131,7 @@ impl From<&SonarrData<'_>> for EditIndexerModal {
} }
#[derive(Default)] #[derive(Default)]
#[cfg_attr(test, derive(Debug))]
pub struct EditSeriesModal { pub struct EditSeriesModal {
pub series_type_list: StatefulList<SeriesType>, pub series_type_list: StatefulList<SeriesType>,
pub quality_profile_list: StatefulList<String>, pub quality_profile_list: StatefulList<String>,
@@ -260,6 +262,7 @@ impl Default for EpisodeDetailsModal {
} }
} }
#[cfg_attr(test, derive(Debug))]
pub struct SeasonDetailsModal { pub struct SeasonDetailsModal {
pub episodes: StatefulTable<Episode>, pub episodes: StatefulTable<Episode>,
pub episode_files: StatefulTable<EpisodeFile>, pub episode_files: StatefulTable<EpisodeFile>,
@@ -1,9 +1,6 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
mod sonarr_data_tests { mod sonarr_data_tests {
use chrono::{DateTime, Utc};
use pretty_assertions::{assert_eq, assert_str_eq};
use crate::app::sonarr::sonarr_context_clues::SERIES_HISTORY_CONTEXT_CLUES; use crate::app::sonarr::sonarr_context_clues::SERIES_HISTORY_CONTEXT_CLUES;
use crate::models::sonarr_models::{Season, SonarrHistoryItem}; use crate::models::sonarr_models::{Season, SonarrHistoryItem};
use crate::models::stateful_table::StatefulTable; use crate::models::stateful_table::StatefulTable;
@@ -22,6 +19,10 @@ mod tests {
servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}, servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData},
}, },
}; };
use bimap::BiMap;
use chrono::{DateTime, Utc};
use pretty_assertions::{assert_eq, assert_str_eq};
use serde_json::Number;
#[test] #[test]
fn test_from_active_sonarr_block_to_route() { fn test_from_active_sonarr_block_to_route() {
@@ -77,44 +78,105 @@ mod tests {
assert_eq!(sonarr_data.series_info_tabs.index, 0); assert_eq!(sonarr_data.series_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 sonarr_data = SonarrData {
tags_map,
..SonarrData::default()
};
assert_str_eq!(
sonarr_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 3".to_owned());
quality_profile_map.insert(2, "test 2".to_owned());
quality_profile_map.insert(1, "test 1".to_owned());
let sonarr_data = SonarrData {
quality_profile_map,
..SonarrData::default()
};
let expected_quality_profile_vec = vec![
"test 1".to_owned(),
"test 2".to_owned(),
"test 3".to_owned(),
];
assert_iter_eq!(
sonarr_data.sorted_quality_profile_names(),
expected_quality_profile_vec
);
}
#[test]
fn test_sorted_language_profile_names() {
let mut language_profiles_map = BiMap::new();
language_profiles_map.insert(3, "test 3".to_owned());
language_profiles_map.insert(2, "test 2".to_owned());
language_profiles_map.insert(1, "test 1".to_owned());
let sonarr_data = SonarrData {
language_profiles_map,
..SonarrData::default()
};
let expected_language_profiles_vec = vec![
"test 1".to_owned(),
"test 2".to_owned(),
"test 3".to_owned(),
];
assert_iter_eq!(
sonarr_data.sorted_language_profile_names(),
expected_language_profiles_vec
);
}
#[test] #[test]
fn test_sonarr_data_defaults() { fn test_sonarr_data_defaults() {
let sonarr_data = SonarrData::default(); let sonarr_data = SonarrData::default();
assert!(!sonarr_data.add_list_exclusion); assert!(!sonarr_data.add_list_exclusion);
assert!(sonarr_data.add_searched_series.is_none()); assert_none!(sonarr_data.add_searched_series);
assert!(sonarr_data.add_series_search.is_none()); assert_none!(sonarr_data.add_series_search);
assert!(sonarr_data.add_series_modal.is_none()); assert_none!(sonarr_data.add_series_modal);
assert!(sonarr_data.blocklist.is_empty()); assert_is_empty!(sonarr_data.blocklist);
assert!(!sonarr_data.delete_series_files); assert!(!sonarr_data.delete_series_files);
assert!(sonarr_data.downloads.is_empty()); assert_is_empty!(sonarr_data.downloads);
assert!(sonarr_data.disk_space_vec.is_empty()); assert_is_empty!(sonarr_data.disk_space_vec);
assert!(sonarr_data.edit_indexer_modal.is_none()); assert_none!(sonarr_data.edit_indexer_modal);
assert!(sonarr_data.edit_root_folder.is_none()); assert_none!(sonarr_data.edit_root_folder);
assert!(sonarr_data.edit_series_modal.is_none()); assert_none!(sonarr_data.edit_series_modal);
assert!(sonarr_data.history.is_empty()); assert_is_empty!(sonarr_data.history);
assert!(sonarr_data.indexers.is_empty()); assert_is_empty!(sonarr_data.indexers);
assert!(sonarr_data.indexer_settings.is_none()); assert_none!(sonarr_data.indexer_settings);
assert!(sonarr_data.indexer_test_errors.is_none()); assert_none!(sonarr_data.indexer_test_errors);
assert!(sonarr_data.indexer_test_all_results.is_none()); assert_none!(sonarr_data.indexer_test_all_results);
assert!(sonarr_data.language_profiles_map.is_empty()); assert_is_empty!(sonarr_data.language_profiles_map);
assert!(sonarr_data.logs.is_empty()); assert_is_empty!(sonarr_data.logs);
assert!(sonarr_data.log_details.is_empty()); assert_is_empty!(sonarr_data.log_details);
assert!(!sonarr_data.prompt_confirm); assert!(!sonarr_data.prompt_confirm);
assert!(sonarr_data.prompt_confirm_action.is_none()); assert_none!(sonarr_data.prompt_confirm_action);
assert!(sonarr_data.quality_profile_map.is_empty()); assert_is_empty!(sonarr_data.quality_profile_map);
assert!(sonarr_data.queued_events.is_empty()); assert_is_empty!(sonarr_data.queued_events);
assert!(sonarr_data.root_folders.is_empty()); assert_is_empty!(sonarr_data.root_folders);
assert!(sonarr_data.seasons.is_empty()); assert_is_empty!(sonarr_data.seasons);
assert!(sonarr_data.season_details_modal.is_none()); assert_none!(sonarr_data.season_details_modal);
assert_eq!(sonarr_data.selected_block, BlockSelectionState::default()); assert_eq!(sonarr_data.selected_block, BlockSelectionState::default());
assert!(sonarr_data.series.is_empty()); assert_is_empty!(sonarr_data.series);
assert!(sonarr_data.series_history.is_none()); assert_none!(sonarr_data.series_history);
assert_eq!(sonarr_data.start_time, <DateTime<Utc>>::default()); assert_eq!(sonarr_data.start_time, <DateTime<Utc>>::default());
assert!(sonarr_data.tags_map.is_empty()); assert_is_empty!(sonarr_data.tags_map);
assert!(sonarr_data.tasks.is_empty()); assert_is_empty!(sonarr_data.tasks);
assert!(sonarr_data.updates.is_empty()); assert_is_empty!(sonarr_data.updates);
assert!(sonarr_data.version.is_empty()); assert_is_empty!(sonarr_data.version);
assert_eq!(sonarr_data.main_tabs.tabs.len(), 7); assert_eq!(sonarr_data.main_tabs.tabs.len(), 7);
@@ -123,84 +185,77 @@ mod tests {
sonarr_data.main_tabs.tabs[0].route, sonarr_data.main_tabs.tabs[0].route,
ActiveSonarrBlock::Series.into() ActiveSonarrBlock::Series.into()
); );
assert!(sonarr_data.main_tabs.tabs[0].contextual_help.is_some()); assert_some_eq_x!(
assert_eq!( &sonarr_data.main_tabs.tabs[0].contextual_help,
sonarr_data.main_tabs.tabs[0].contextual_help.unwrap(),
&SERIES_CONTEXT_CLUES &SERIES_CONTEXT_CLUES
); );
assert_eq!(sonarr_data.main_tabs.tabs[0].config, None); assert_none!(sonarr_data.main_tabs.tabs[0].config);
assert_str_eq!(sonarr_data.main_tabs.tabs[1].title, "Downloads"); assert_str_eq!(sonarr_data.main_tabs.tabs[1].title, "Downloads");
assert_eq!( assert_eq!(
sonarr_data.main_tabs.tabs[1].route, sonarr_data.main_tabs.tabs[1].route,
ActiveSonarrBlock::Downloads.into() ActiveSonarrBlock::Downloads.into()
); );
assert!(sonarr_data.main_tabs.tabs[1].contextual_help.is_some()); assert_some_eq_x!(
assert_eq!( &sonarr_data.main_tabs.tabs[1].contextual_help,
sonarr_data.main_tabs.tabs[1].contextual_help.unwrap(),
&DOWNLOADS_CONTEXT_CLUES &DOWNLOADS_CONTEXT_CLUES
); );
assert_eq!(sonarr_data.main_tabs.tabs[1].config, None); assert_none!(sonarr_data.main_tabs.tabs[1].config);
assert_str_eq!(sonarr_data.main_tabs.tabs[2].title, "Blocklist"); assert_str_eq!(sonarr_data.main_tabs.tabs[2].title, "Blocklist");
assert_eq!( assert_eq!(
sonarr_data.main_tabs.tabs[2].route, sonarr_data.main_tabs.tabs[2].route,
ActiveSonarrBlock::Blocklist.into() ActiveSonarrBlock::Blocklist.into()
); );
assert!(sonarr_data.main_tabs.tabs[2].contextual_help.is_some()); assert_some_eq_x!(
assert_eq!( &sonarr_data.main_tabs.tabs[2].contextual_help,
sonarr_data.main_tabs.tabs[2].contextual_help.unwrap(),
&BLOCKLIST_CONTEXT_CLUES &BLOCKLIST_CONTEXT_CLUES
); );
assert_eq!(sonarr_data.main_tabs.tabs[2].config, None); assert_none!(sonarr_data.main_tabs.tabs[2].config);
assert_str_eq!(sonarr_data.main_tabs.tabs[3].title, "History"); assert_str_eq!(sonarr_data.main_tabs.tabs[3].title, "History");
assert_eq!( assert_eq!(
sonarr_data.main_tabs.tabs[3].route, sonarr_data.main_tabs.tabs[3].route,
ActiveSonarrBlock::History.into() ActiveSonarrBlock::History.into()
); );
assert!(sonarr_data.main_tabs.tabs[3].contextual_help.is_some()); assert_some_eq_x!(
assert_eq!( &sonarr_data.main_tabs.tabs[3].contextual_help,
sonarr_data.main_tabs.tabs[3].contextual_help.unwrap(),
&HISTORY_CONTEXT_CLUES &HISTORY_CONTEXT_CLUES
); );
assert_eq!(sonarr_data.main_tabs.tabs[3].config, None); assert_none!(sonarr_data.main_tabs.tabs[3].config);
assert_str_eq!(sonarr_data.main_tabs.tabs[4].title, "Root Folders"); assert_str_eq!(sonarr_data.main_tabs.tabs[4].title, "Root Folders");
assert_eq!( assert_eq!(
sonarr_data.main_tabs.tabs[4].route, sonarr_data.main_tabs.tabs[4].route,
ActiveSonarrBlock::RootFolders.into() ActiveSonarrBlock::RootFolders.into()
); );
assert!(sonarr_data.main_tabs.tabs[4].contextual_help.is_some()); assert_some_eq_x!(
assert_eq!( &sonarr_data.main_tabs.tabs[4].contextual_help,
sonarr_data.main_tabs.tabs[4].contextual_help.unwrap(),
&ROOT_FOLDERS_CONTEXT_CLUES &ROOT_FOLDERS_CONTEXT_CLUES
); );
assert_eq!(sonarr_data.main_tabs.tabs[4].config, None); assert_none!(sonarr_data.main_tabs.tabs[4].config);
assert_str_eq!(sonarr_data.main_tabs.tabs[5].title, "Indexers"); assert_str_eq!(sonarr_data.main_tabs.tabs[5].title, "Indexers");
assert_eq!( assert_eq!(
sonarr_data.main_tabs.tabs[5].route, sonarr_data.main_tabs.tabs[5].route,
ActiveSonarrBlock::Indexers.into() ActiveSonarrBlock::Indexers.into()
); );
assert!(sonarr_data.main_tabs.tabs[5].contextual_help.is_some()); assert_some_eq_x!(
assert_eq!( &sonarr_data.main_tabs.tabs[5].contextual_help,
sonarr_data.main_tabs.tabs[5].contextual_help.unwrap(),
&INDEXERS_CONTEXT_CLUES &INDEXERS_CONTEXT_CLUES
); );
assert_eq!(sonarr_data.main_tabs.tabs[5].config, None); assert_none!(sonarr_data.main_tabs.tabs[5].config);
assert_str_eq!(sonarr_data.main_tabs.tabs[6].title, "System"); assert_str_eq!(sonarr_data.main_tabs.tabs[6].title, "System");
assert_eq!( assert_eq!(
sonarr_data.main_tabs.tabs[6].route, sonarr_data.main_tabs.tabs[6].route,
ActiveSonarrBlock::System.into() ActiveSonarrBlock::System.into()
); );
assert!(sonarr_data.main_tabs.tabs[6].contextual_help.is_some()); assert_some_eq_x!(
assert_eq!( &sonarr_data.main_tabs.tabs[6].contextual_help,
sonarr_data.main_tabs.tabs[6].contextual_help.unwrap(),
&SYSTEM_CONTEXT_CLUES &SYSTEM_CONTEXT_CLUES
); );
assert_eq!(sonarr_data.main_tabs.tabs[6].config, None); assert_none!(sonarr_data.main_tabs.tabs[6].config);
assert_eq!(sonarr_data.series_info_tabs.tabs.len(), 2); assert_eq!(sonarr_data.series_info_tabs.tabs.len(), 2);
@@ -209,36 +264,22 @@ mod tests {
sonarr_data.series_info_tabs.tabs[0].route, sonarr_data.series_info_tabs.tabs[0].route,
ActiveSonarrBlock::SeriesDetails.into() ActiveSonarrBlock::SeriesDetails.into()
); );
assert!( assert_some_eq_x!(
sonarr_data.series_info_tabs.tabs[0] &sonarr_data.series_info_tabs.tabs[0].contextual_help,
.contextual_help
.is_some()
);
assert_eq!(
sonarr_data.series_info_tabs.tabs[0]
.contextual_help
.unwrap(),
&SERIES_DETAILS_CONTEXT_CLUES &SERIES_DETAILS_CONTEXT_CLUES
); );
assert_eq!(sonarr_data.series_info_tabs.tabs[0].config, None); assert_none!(sonarr_data.series_info_tabs.tabs[0].config);
assert_str_eq!(sonarr_data.series_info_tabs.tabs[1].title, "History"); assert_str_eq!(sonarr_data.series_info_tabs.tabs[1].title, "History");
assert_eq!( assert_eq!(
sonarr_data.series_info_tabs.tabs[1].route, sonarr_data.series_info_tabs.tabs[1].route,
ActiveSonarrBlock::SeriesHistory.into() ActiveSonarrBlock::SeriesHistory.into()
); );
assert!( assert_some_eq_x!(
sonarr_data.series_info_tabs.tabs[1] &sonarr_data.series_info_tabs.tabs[1].contextual_help,
.contextual_help
.is_some()
);
assert_eq!(
sonarr_data.series_info_tabs.tabs[1]
.contextual_help
.unwrap(),
&SERIES_HISTORY_CONTEXT_CLUES &SERIES_HISTORY_CONTEXT_CLUES
); );
assert_eq!(sonarr_data.series_info_tabs.tabs[1].config, None); assert_none!(sonarr_data.series_info_tabs.tabs[1].config);
} }
} }
+17 -1
View File
@@ -174,9 +174,25 @@ where
} }
pub fn set_filtered_items(&mut self, filtered_items: Vec<T>) { pub fn set_filtered_items(&mut self, filtered_items: Vec<T>) {
let items_len = filtered_items.len();
self.filtered_items = Some(filtered_items); self.filtered_items = Some(filtered_items);
let preserved_selection = self
.filtered_state
.as_ref()
.and_then(|state| state.selected())
.map_or(0, |i| {
if i > 0 && i < items_len {
i
} else if i >= items_len && items_len > 0 {
items_len - 1
} else {
0
}
});
let mut filtered_state: TableState = Default::default(); let mut filtered_state: TableState = Default::default();
filtered_state.select(Some(0)); filtered_state.select(Some(preserved_selection));
self.filtered_state = Some(filtered_state); self.filtered_state = Some(filtered_state);
} }
+41
View File
@@ -390,6 +390,47 @@ mod tests {
assert_some_eq_x!(&filtered_stateful_table.filtered_items, &filtered_items_vec); assert_some_eq_x!(&filtered_stateful_table.filtered_items, &filtered_items_vec);
} }
#[test]
fn test_stateful_table_set_filtered_items_preserves_selection() {
let filtered_items_vec = vec!["Test 1", "Test 2", "Test 3"];
let mut filtered_stateful_table: StatefulTable<&str> = StatefulTable::default();
filtered_stateful_table.set_filtered_items(filtered_items_vec.clone());
filtered_stateful_table
.filtered_state
.as_mut()
.unwrap()
.select(Some(1));
filtered_stateful_table.set_filtered_items(filtered_items_vec.clone());
assert_some_eq_x!(
filtered_stateful_table
.filtered_state
.as_ref()
.unwrap()
.selected(),
1
);
filtered_stateful_table
.filtered_state
.as_mut()
.unwrap()
.select(Some(5));
filtered_stateful_table.set_filtered_items(filtered_items_vec);
assert_some_eq_x!(
filtered_stateful_table
.filtered_state
.as_ref()
.unwrap()
.selected(),
2
);
}
#[test] #[test]
fn test_stateful_table_current_selection() { fn test_stateful_table_current_selection() {
let mut stateful_table = create_test_stateful_table(); let mut stateful_table = create_test_stateful_table();
@@ -0,0 +1,43 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{DownloadsResponse, LidarrSerdeable};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use pretty_assertions::assert_eq;
use serde_json::json;
#[tokio::test]
async fn test_handle_get_downloads_event() {
let downloads_json = json!({
"records": [{
"title": "Test Album",
"status": "downloading",
"id": 1,
"size": 100.0,
"sizeleft": 50.0,
"indexer": "test-indexer"
}]
});
let response: DownloadsResponse = serde_json::from_value(downloads_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(downloads_json)
.query("pageSize=500")
.build_for(LidarrEvent::GetDownloads(500))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::GetDownloads(500))
.await;
mock.assert_async().await;
let LidarrSerdeable::DownloadsResponse(downloads_response) = result.unwrap() else {
panic!("Expected DownloadsResponse");
};
assert_eq!(downloads_response, response);
assert!(!app.lock().await.data.lidarr_data.downloads.is_empty());
}
}
@@ -0,0 +1,40 @@
use anyhow::Result;
use log::info;
use crate::models::lidarr_models::DownloadsResponse;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
#[cfg(test)]
#[path = "lidarr_downloads_network_tests.rs"]
mod lidarr_downloads_network_tests;
impl Network<'_, '_> {
pub(in crate::network::lidarr_network) async fn get_lidarr_downloads(
&mut self,
count: u64,
) -> Result<DownloadsResponse> {
info!("Fetching Lidarr downloads");
let event = LidarrEvent::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
.lidarr_data
.downloads
.set_items(queue_response.records);
})
.await
}
}
@@ -0,0 +1,442 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{
AddArtistSearchResult, Artist, DeleteArtistParams, EditArtistParams, LidarrSerdeable,
NewItemMonitorType,
};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::network::NetworkResource;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON,
};
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use bimap::BiMap;
use mockito::Matcher;
use pretty_assertions::assert_eq;
use serde_json::{Value, json};
#[tokio::test]
async fn test_handle_list_artists_event() {
let artists_json = json!([{
"id": 1,
"artistName": "Test Artist",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"path": "/music/test-artist",
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": [],
"tags": [],
"added": "2023-01-01T00:00:00Z"
}]);
let response: Vec<Artist> = serde_json::from_value(artists_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(artists_json)
.build_for(LidarrEvent::ListArtists)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network.handle_lidarr_event(LidarrEvent::ListArtists).await;
mock.assert_async().await;
let LidarrSerdeable::Artists(artists) = result.unwrap() else {
panic!("Expected Artists");
};
assert_eq!(artists, response);
assert!(!app.lock().await.data.lidarr_data.artists.is_empty());
}
#[tokio::test]
async fn test_handle_delete_artist_event() {
let delete_artist_params = DeleteArtistParams {
id: 1,
delete_files: true,
add_import_list_exclusion: true,
};
let (async_server, app, _server) = MockServarrApi::delete()
.path("/1")
.query("deleteFiles=true&addImportListExclusion=true")
.build_for(LidarrEvent::DeleteArtist(delete_artist_params.clone()))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::DeleteArtist(delete_artist_params))
.await
.is_ok()
);
async_server.assert_async().await;
}
#[tokio::test]
async fn test_handle_get_artist_details_event() {
let expected_artist: Artist = serde_json::from_str(ARTIST_JSON).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::GetArtistDetails(1))
.await;
mock.assert_async().await;
let LidarrSerdeable::Artist(artist) = result.unwrap() else {
panic!("Expected Artist");
};
assert_eq!(artist, expected_artist);
}
#[tokio::test]
async fn test_handle_toggle_artist_monitoring_event() {
let artist_json = json!({
"id": 1,
"artistName": "Test Artist",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"path": "/music/test-artist",
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": [],
"tags": [],
"added": "2023-01-01T00:00:00Z"
});
let mut expected_body = artist_json.clone();
*expected_body.get_mut("monitored").unwrap() = json!(false);
let (get_mock, app, mut server) = MockServarrApi::get()
.returns(artist_json)
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
let put_mock = server
.mock("PUT", "/api/v1/artist/1")
.match_body(Matcher::Json(expected_body))
.match_header("X-Api-Key", "test1234")
.with_status(202)
.create_async()
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::ToggleArtistMonitoring(1))
.await
.is_ok()
);
get_mock.assert_async().await;
put_mock.assert_async().await;
}
#[tokio::test]
async fn test_handle_update_all_artists_event() {
let (mock, app, _server) = MockServarrApi::post()
.with_request_body(json!({
"name": "RefreshArtist"
}))
.returns(json!({}))
.build_for(LidarrEvent::UpdateAllArtists)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::UpdateAllArtists)
.await
.is_ok()
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_handle_edit_artist_event() {
let mut expected_body: Value = serde_json::from_str(ARTIST_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_artist_params = EditArtistParams {
artist_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()),
..EditArtistParams::default()
};
let (async_details_server, app, mut server) = MockServarrApi::get()
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
let async_edit_server = server
.mock(
"PUT",
format!(
"/api/v1{}/1",
LidarrEvent::EditArtist(edit_artist_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.data.lidarr_data.tags_map =
BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params))
.await
.is_ok()
);
async_details_server.assert_async().await;
async_edit_server.assert_async().await;
}
#[tokio::test]
async fn test_handle_edit_artist_event_does_not_overwrite_tag_ids_vec_when_tag_input_string_is_none()
{
let mut expected_body: Value = serde_json::from_str(ARTIST_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_artist_params = EditArtistParams {
artist_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]),
..EditArtistParams::default()
};
let (async_details_server, app, mut server) = MockServarrApi::get()
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
let async_edit_server = server
.mock(
"PUT",
format!(
"/api/v1{}/1",
LidarrEvent::EditArtist(edit_artist_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.data.lidarr_data.tags_map =
BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params))
.await
.is_ok()
);
async_details_server.assert_async().await;
async_edit_server.assert_async().await;
}
#[tokio::test]
async fn test_handle_edit_artist_event_defaults_to_previous_values() {
let edit_artist_params = EditArtistParams {
artist_id: 1,
..EditArtistParams::default()
};
let expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap();
let (async_details_server, app, mut server) = MockServarrApi::get()
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
let async_edit_server = server
.mock(
"PUT",
format!(
"/api/v1{}/1",
LidarrEvent::EditArtist(edit_artist_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(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params))
.await
.is_ok()
);
async_details_server.assert_async().await;
async_edit_server.assert_async().await;
}
#[tokio::test]
async fn test_handle_edit_artist_event_returns_empty_tags_vec_when_clear_tags_is_true() {
let mut expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap();
*expected_body.get_mut("tags").unwrap() = json!([]);
let (async_details_server, app, mut server) = MockServarrApi::get()
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
let edit_artist_params = EditArtistParams {
artist_id: 1,
clear_tags: true,
..EditArtistParams::default()
};
let async_edit_server = server
.mock(
"PUT",
format!(
"/api/v1{}/1",
LidarrEvent::EditArtist(edit_artist_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(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params))
.await
.is_ok()
);
async_details_server.assert_async().await;
async_edit_server.assert_async().await;
}
#[tokio::test]
async fn test_handle_search_new_artist_event() {
let search_results_json =
json!([serde_json::from_str::<Value>(ADD_ARTIST_SEARCH_RESULT_JSON).unwrap()]);
let expected_results: Vec<AddArtistSearchResult> =
serde_json::from_value(search_results_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(search_results_json)
.query("term=test%20artist")
.build_for(LidarrEvent::SearchNewArtist("test artist".to_owned()))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::SearchNewArtist("test artist".to_owned()))
.await;
mock.assert_async().await;
let LidarrSerdeable::AddArtistSearchResults(search_results) = result.unwrap() else {
panic!("Expected AddArtistSearchResults");
};
assert_eq!(search_results, expected_results);
assert_some!(&app.lock().await.data.lidarr_data.add_searched_artists);
}
#[tokio::test]
async fn test_handle_search_new_artist_event_navigates_to_empty_results_when_empty() {
let (mock, app, _server) = MockServarrApi::get()
.returns(json!([]))
.query("term=nonexistent")
.build_for(LidarrEvent::SearchNewArtist("nonexistent".to_owned()))
.await;
app.lock().await.server_tabs.set_index(2);
app
.lock()
.await
.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into());
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::SearchNewArtist("nonexistent".to_owned()))
.await;
mock.assert_async().await;
assert_ok!(result);
let app = app.lock().await;
assert_none!(&app.data.lidarr_data.add_searched_artists);
assert_eq!(
app.get_current_route(),
ActiveLidarrBlock::AddArtistEmptySearchResults.into()
);
}
#[tokio::test]
async fn test_handle_search_new_artist_event_sets_empty_table_on_api_error() {
let (mock, app, _server) = MockServarrApi::get()
.status(500)
.query("term=nonexistent")
.build_for(LidarrEvent::SearchNewArtist("nonexistent".to_owned()))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::SearchNewArtist("nonexistent".to_owned()))
.await;
mock.assert_async().await;
assert_err!(result);
let app = app.lock().await;
assert_some!(&app.data.lidarr_data.add_searched_artists);
assert_is_empty!(app.data.lidarr_data.add_searched_artists.as_ref().unwrap());
}
}
+330
View File
@@ -0,0 +1,330 @@
use anyhow::Result;
use log::{debug, info, warn};
use serde_json::{Value, json};
use crate::models::Route;
use crate::models::lidarr_models::{
AddArtistSearchResult, Artist, DeleteArtistParams, EditArtistParams,
};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::servarr_models::CommandBody;
use crate::models::stateful_table::StatefulTable;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
use urlencoding::encode;
#[cfg(test)]
#[path = "lidarr_library_network_tests.rs"]
mod lidarr_library_network_tests;
impl Network<'_, '_> {
pub(in crate::network::lidarr_network) async fn delete_artist(
&mut self,
delete_artist_params: DeleteArtistParams,
) -> Result<()> {
let event = LidarrEvent::DeleteArtist(DeleteArtistParams::default());
let DeleteArtistParams {
id,
delete_files,
add_import_list_exclusion,
} = delete_artist_params;
info!(
"Deleting Lidarr artist 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::lidarr_network) async fn list_artists(&mut self) -> Result<Vec<Artist>> {
info!("Fetching Lidarr artists");
let event = LidarrEvent::ListArtists;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<Artist>>(request_props, |mut artists_vec, mut app| {
if !matches!(
app.get_current_route(),
Route::Lidarr(ActiveLidarrBlock::ArtistsSortPrompt, _)
) {
artists_vec.sort_by(|a, b| a.id.cmp(&b.id));
app.data.lidarr_data.artists.set_items(artists_vec);
app.data.lidarr_data.artists.apply_sorting_toggle(false);
}
})
.await
}
pub(in crate::network::lidarr_network) async fn get_artist_details(
&mut self,
artist_id: i64,
) -> Result<Artist> {
info!("Fetching details for Lidarr artist with ID: {artist_id}");
let event = LidarrEvent::GetArtistDetails(artist_id);
let request_props = self
.request_props_from(
event,
RequestMethod::Get,
None::<()>,
Some(format!("/{artist_id}")),
None,
)
.await;
self
.handle_request::<(), Artist>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn toggle_artist_monitoring(
&mut self,
artist_id: i64,
) -> Result<()> {
let event = LidarrEvent::ToggleArtistMonitoring(artist_id);
let detail_event = LidarrEvent::GetArtistDetails(artist_id);
info!("Toggling artist monitoring for artist with ID: {artist_id}");
info!("Fetching artist details for artist with ID: {artist_id}");
let request_props = self
.request_props_from(
detail_event,
RequestMethod::Get,
None::<()>,
Some(format!("/{artist_id}")),
None,
)
.await;
let mut response = String::new();
self
.handle_request::<(), Value>(request_props, |detailed_artist_body, _| {
response = detailed_artist_body.to_string()
})
.await?;
info!("Constructing toggle artist monitoring body");
match serde_json::from_str::<Value>(&response) {
Ok(mut detailed_artist_body) => {
let monitored = detailed_artist_body
.get("monitored")
.unwrap()
.as_bool()
.unwrap();
*detailed_artist_body.get_mut("monitored").unwrap() = json!(!monitored);
debug!("Toggle artist monitoring body: {detailed_artist_body:?}");
let request_props = self
.request_props_from(
event,
RequestMethod::Put,
Some(detailed_artist_body),
Some(format!("/{artist_id}")),
None,
)
.await;
self
.handle_request::<Value, ()>(request_props, |_, _| ())
.await
}
Err(_) => {
warn!("Request for detailed artist body was interrupted");
Ok(())
}
}
}
pub(in crate::network::lidarr_network) async fn update_all_artists(&mut self) -> Result<Value> {
info!("Updating all artists");
let event = LidarrEvent::UpdateAllArtists;
let body = CommandBody {
name: "RefreshArtist".to_owned(),
};
let request_props = self
.request_props_from(event, RequestMethod::Post, Some(body), None, None)
.await;
self
.handle_request::<CommandBody, Value>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn search_artist(
&mut self,
query: String,
) -> Result<Vec<AddArtistSearchResult>> {
info!("Searching for artist: {query}");
let event = LidarrEvent::SearchNewArtist(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<AddArtistSearchResult>>(request_props, |artist_vec, mut app| {
if artist_vec.is_empty() {
app.pop_and_push_navigation_stack(ActiveLidarrBlock::AddArtistEmptySearchResults.into());
} else if let Some(add_searched_artists) =
app.data.lidarr_data.add_searched_artists.as_mut()
{
add_searched_artists.set_items(artist_vec);
} else {
let mut add_searched_artists = StatefulTable::default();
add_searched_artists.set_items(artist_vec);
app.data.lidarr_data.add_searched_artists = Some(add_searched_artists);
}
})
.await;
if result.is_err() {
self.app.lock().await.data.lidarr_data.add_searched_artists = Some(StatefulTable::default());
}
result
}
pub(in crate::network::lidarr_network) async fn edit_artist(
&mut self,
mut edit_artist_params: EditArtistParams,
) -> Result<()> {
info!("Editing Lidarr artist");
if let Some(tag_input_str) = edit_artist_params.tag_input_string.as_ref() {
let tag_ids_vec = self.extract_and_add_lidarr_tag_ids_vec(tag_input_str).await;
edit_artist_params.tags = Some(tag_ids_vec);
}
let artist_id = edit_artist_params.artist_id;
let detail_event = LidarrEvent::GetArtistDetails(artist_id);
let event = LidarrEvent::EditArtist(EditArtistParams::default());
info!("Fetching artist details for artist with ID: {artist_id}");
let request_props = self
.request_props_from(
detail_event,
RequestMethod::Get,
None::<()>,
Some(format!("/{artist_id}")),
None,
)
.await;
let mut response = String::new();
self
.handle_request::<(), Value>(request_props, |detailed_artist_body, _| {
response = detailed_artist_body.to_string()
})
.await?;
info!("Constructing edit artist body");
let mut detailed_artist_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_artist_params.monitored.unwrap_or(
detailed_artist_body["monitored"]
.as_bool()
.expect("Unable to deserialize 'monitored'"),
);
let monitor_new_items = edit_artist_params.monitor_new_items.unwrap_or_else(|| {
serde_json::from_value(detailed_artist_body["monitorNewItems"].clone())
.expect("Unable to deserialize 'monitorNewItems'")
});
let quality_profile_id = edit_artist_params.quality_profile_id.unwrap_or_else(|| {
detailed_artist_body["qualityProfileId"]
.as_i64()
.expect("Unable to deserialize 'qualityProfileId'")
});
let metadata_profile_id = edit_artist_params.metadata_profile_id.unwrap_or_else(|| {
detailed_artist_body["metadataProfileId"]
.as_i64()
.expect("Unable to deserialize 'metadataProfileId'")
});
let root_folder_path = edit_artist_params.root_folder_path.unwrap_or_else(|| {
detailed_artist_body["path"]
.as_str()
.expect("Unable to deserialize 'path'")
.to_owned()
});
let tags = if edit_artist_params.clear_tags {
vec![]
} else {
edit_artist_params.tags.unwrap_or(
detailed_artist_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_artist_body.get_mut("monitored").unwrap() = json!(monitored);
*detailed_artist_body.get_mut("monitorNewItems").unwrap() = json!(monitor_new_items);
*detailed_artist_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id);
*detailed_artist_body.get_mut("metadataProfileId").unwrap() = json!(metadata_profile_id);
*detailed_artist_body.get_mut("path").unwrap() = json!(root_folder_path);
*detailed_artist_body.get_mut("tags").unwrap() = json!(tags);
debug!("Edit artist body: {detailed_artist_body:?}");
let request_props = self
.request_props_from(
event,
RequestMethod::Put,
Some(detailed_artist_body),
Some(format!("/{artist_id}")),
None,
)
.await;
self
.handle_request::<Value, ()>(request_props, |_, _| ())
.await
}
}
@@ -0,0 +1,202 @@
#[cfg(test)]
#[allow(dead_code)]
pub mod test_utils {
use crate::models::HorizontallyScrollableText;
use crate::models::lidarr_models::{
AddArtistSearchResult, Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus,
DownloadsResponse, EditArtistParams, Member, MetadataProfile, NewItemMonitorType, Ratings,
SystemStatus,
};
use crate::models::servarr_models::{QualityProfile, RootFolder, Tag};
use bimap::BiMap;
use chrono::DateTime;
use serde_json::Number;
pub const ADD_ARTIST_SEARCH_RESULT_JSON: &str = r#"{
"foreignArtistId": "test-foreign-id",
"artistName": "Test Artist",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"genres": ["soundtrack"],
"ratings": { "votes": 15, "value": 8.4 }
}"#;
pub const ARTIST_JSON: &str = r#"{
"id": 1,
"artistName": "Test Artist",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/music/test-artist",
"members": [{"name": "alex", "instrument": "piano"}],
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": ["soundtrack"],
"tags": [1],
"added": "2023-01-01T00:00:00Z",
"ratings": { "votes": 15, "value": 8.4 },
"statistics": {
"albumCount": 1,
"trackFileCount": 15,
"trackCount": 15,
"totalTrackCount": 15,
"sizeOnDisk": 12345,
"percentOfTracks": 99.9
}
}"#;
pub fn member() -> Member {
Member {
name: Some("alex".to_owned()),
instrument: Some("piano".to_owned()),
}
}
pub fn ratings() -> Ratings {
Ratings {
votes: 15,
value: 8.4,
}
}
pub fn artist_statistics() -> ArtistStatistics {
ArtistStatistics {
album_count: 1,
track_file_count: 15,
track_count: 15,
total_track_count: 15,
size_on_disk: 12345,
percent_of_tracks: 99.9,
}
}
pub fn artist() -> Artist {
Artist {
id: 1,
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()),
artist_type: Some("Person".to_owned()),
disambiguation: Some("American pianist".to_owned()),
members: Some(vec![member()]),
path: "/nfs/music/test-artist".to_owned(),
quality_profile_id: quality_profile().id,
metadata_profile_id: metadata_profile().id,
monitored: true,
monitor_new_items: NewItemMonitorType::All,
genres: vec!["soundtrack".to_owned()],
tags: vec![Number::from(tag().id)],
added: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()),
ratings: Some(ratings()),
statistics: Some(artist_statistics()),
}
}
pub fn quality_profile() -> QualityProfile {
QualityProfile {
id: 1,
name: "Lossless".to_owned(),
}
}
pub fn quality_profile_map() -> BiMap<i64, String> {
let quality_profile = quality_profile();
BiMap::from_iter(vec![(quality_profile.id, quality_profile.name)])
}
pub fn metadata_profile() -> MetadataProfile {
MetadataProfile {
id: 1,
name: "Standard".to_owned(),
}
}
pub fn metadata_profile_map() -> BiMap<i64, String> {
let metadata_profile = metadata_profile();
BiMap::from_iter(vec![(metadata_profile.id, metadata_profile.name)])
}
pub fn tag() -> Tag {
Tag {
id: 1,
label: "alex".to_owned(),
}
}
pub fn tags_map() -> BiMap<i64, String> {
let tag = tag();
BiMap::from_iter(vec![(tag.id, tag.label)])
}
pub fn download_record() -> DownloadRecord {
DownloadRecord {
title: "Test download title".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
album_id: Some(Number::from(1i64)),
artist_id: Some(Number::from(1i64)),
size: 3543348019f64,
sizeleft: 1771674009f64,
output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")),
indexer: "kickass torrents".to_owned(),
download_client: Some("transmission".to_owned()),
}
}
pub fn downloads_response() -> DownloadsResponse {
DownloadsResponse {
records: vec![download_record()],
}
}
pub fn system_status() -> SystemStatus {
SystemStatus {
version: "1.0".to_owned(),
start_time: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()),
}
}
pub fn root_folder() -> RootFolder {
RootFolder {
id: 1,
path: "/nfs".to_owned(),
accessible: true,
free_space: 219902325555200,
unmapped_folders: None,
}
}
pub fn edit_artist_params() -> EditArtistParams {
EditArtistParams {
artist_id: artist().id,
monitored: Some(true),
monitor_new_items: Some(NewItemMonitorType::All),
quality_profile_id: Some(quality_profile().id),
metadata_profile_id: Some(metadata_profile().id),
root_folder_path: Some("/nfs/music/test-artist".to_owned()),
tags: Some(vec![tag().id]),
tag_input_string: Some("alex".to_owned()),
clear_tags: false,
}
}
pub fn add_artist_search_result() -> AddArtistSearchResult {
AddArtistSearchResult {
foreign_artist_id: "test-foreign-id".to_owned(),
artist_name: "Test Artist".into(),
status: ArtistStatus::Continuing,
overview: Some("some interesting description of the artist".to_owned()),
artist_type: Some("Person".to_owned()),
disambiguation: Some("American pianist".to_owned()),
genres: vec!["soundtrack".to_owned()],
ratings: Some(ratings()),
}
}
}
@@ -0,0 +1,292 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::models::lidarr_models::{LidarrSerdeable, MetadataProfile};
use crate::models::servarr_data::lidarr::modals::EditArtistModal;
use crate::models::servarr_models::{QualityProfile, Tag};
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent};
use bimap::BiMap;
use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Mutex;
#[rstest]
fn test_resource_artist(
#[values(
LidarrEvent::GetArtistDetails(0),
LidarrEvent::ListArtists,
LidarrEvent::ToggleArtistMonitoring(0)
)]
event: LidarrEvent,
) {
assert_str_eq!(event.resource(), "/artist");
}
#[rstest]
fn test_resource_tag(
#[values(
LidarrEvent::AddTag(String::new()),
LidarrEvent::DeleteTag(0),
LidarrEvent::GetTags
)]
event: LidarrEvent,
) {
assert_str_eq!(event.resource(), "/tag");
}
#[rstest]
fn test_resource_config(
#[values(LidarrEvent::GetHostConfig, LidarrEvent::GetSecurityConfig)] event: LidarrEvent,
) {
assert_str_eq!(event.resource(), "/config/host");
}
#[rstest]
fn test_resource_command(#[values(LidarrEvent::UpdateAllArtists)] event: LidarrEvent) {
assert_str_eq!(event.resource(), "/command");
}
#[rstest]
#[case(LidarrEvent::GetDiskSpace, "/diskspace")]
#[case(LidarrEvent::GetDownloads(500), "/queue")]
#[case(LidarrEvent::GetMetadataProfiles, "/metadataprofile")]
#[case(LidarrEvent::GetQualityProfiles, "/qualityprofile")]
#[case(LidarrEvent::GetRootFolders, "/rootfolder")]
#[case(LidarrEvent::GetStatus, "/system/status")]
#[case(LidarrEvent::GetTags, "/tag")]
#[case(LidarrEvent::HealthCheck, "/health")]
fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) {
assert_str_eq!(event.resource(), expected_uri);
}
#[test]
fn test_from_lidarr_event() {
assert_eq!(
NetworkEvent::Lidarr(LidarrEvent::HealthCheck),
NetworkEvent::from(LidarrEvent::HealthCheck)
);
}
#[tokio::test]
async fn test_handle_get_lidarr_healthcheck_event() {
let (mock, app, _server) = MockServarrApi::get()
.build_for(LidarrEvent::HealthCheck)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let _ = network.handle_lidarr_event(LidarrEvent::HealthCheck).await;
mock.assert_async().await;
}
#[tokio::test]
async fn test_handle_get_metadata_profiles_event() {
let metadata_profiles_json = json!([{
"id": 1,
"name": "Standard"
}]);
let response: Vec<MetadataProfile> =
serde_json::from_value(metadata_profiles_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(metadata_profiles_json)
.build_for(LidarrEvent::GetMetadataProfiles)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::GetMetadataProfiles)
.await;
mock.assert_async().await;
let LidarrSerdeable::MetadataProfiles(metadata_profiles) = result.unwrap() else {
panic!("Expected MetadataProfiles");
};
assert_eq!(metadata_profiles, response);
assert_eq!(
app
.lock()
.await
.data
.lidarr_data
.metadata_profile_map
.get_by_left(&1),
Some(&"Standard".to_owned())
);
}
#[tokio::test]
async fn test_handle_get_quality_profiles_event() {
let quality_profiles_json = json!([{
"id": 1,
"name": "Lossless"
}]);
let response: Vec<QualityProfile> =
serde_json::from_value(quality_profiles_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(quality_profiles_json)
.build_for(LidarrEvent::GetQualityProfiles)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::GetQualityProfiles)
.await;
mock.assert_async().await;
let LidarrSerdeable::QualityProfiles(quality_profiles) = result.unwrap() else {
panic!("Expected QualityProfiles");
};
assert_eq!(quality_profiles, response);
assert_eq!(
app
.lock()
.await
.data
.lidarr_data
.quality_profile_map
.get_by_left(&1),
Some(&"Lossless".to_owned())
);
}
#[tokio::test]
async fn test_handle_get_tags_event() {
let tags_json = json!([{
"id": 1,
"label": "usenet"
}]);
let response: Vec<Tag> = serde_json::from_value(tags_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(tags_json)
.build_for(LidarrEvent::GetTags)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network.handle_lidarr_event(LidarrEvent::GetTags).await;
mock.assert_async().await;
let LidarrSerdeable::Tags(tags) = result.unwrap() else {
panic!("Expected Tags");
};
assert_eq!(tags, response);
assert_eq!(
app.lock().await.data.lidarr_data.tags_map.get_by_left(&1),
Some(&"usenet".to_owned())
);
}
#[tokio::test]
async fn test_handle_add_lidarr_tag_event() {
let tag_json = json!({
"id": 1,
"label": "usenet"
});
let response: Tag = serde_json::from_value(tag_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::post()
.with_request_body(json!({ "label": "usenet" }))
.returns(tag_json)
.build_for(LidarrEvent::AddTag("usenet".to_owned()))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::AddTag("usenet".to_owned()))
.await;
mock.assert_async().await;
let LidarrSerdeable::Tag(tag) = result.unwrap() else {
panic!("Expected Tag");
};
assert_eq!(tag, response);
assert_eq!(
app.lock().await.data.lidarr_data.tags_map.get_by_left(&1),
Some(&"usenet".to_owned())
);
}
#[tokio::test]
async fn test_handle_delete_lidarr_tag_event() {
let (mock, app, _server) = MockServarrApi::delete()
.path("/1")
.build_for(LidarrEvent::DeleteTag(1))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network.handle_lidarr_event(LidarrEvent::DeleteTag(1)).await;
mock.assert_async().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_extract_and_add_lidarr_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.lidarr_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(2);
let mut network = test_network(&app_arc);
assert_eq!(
network.extract_and_add_lidarr_tag_ids_vec(tags).await,
vec![2, 3, 1]
);
}
#[tokio::test]
async fn test_extract_and_add_lidarr_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(LidarrEvent::GetTags)
.await;
let tags = "usenet, test, TESTING";
{
let mut app_guard = app.lock().await;
app_guard.data.lidarr_data.edit_artist_modal = Some(EditArtistModal {
tags: tags.into(),
..EditArtistModal::default()
});
app_guard.data.lidarr_data.tags_map =
BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]);
}
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let tag_ids_vec = network.extract_and_add_lidarr_tag_ids_vec(tags).await;
mock.assert_async().await;
assert_eq!(tag_ids_vec, vec![1, 2, 3]);
assert_eq!(
app.lock().await.data.lidarr_data.tags_map,
BiMap::from_iter([
(1, "usenet".to_owned()),
(2, "test".to_owned()),
(3, "testing".to_owned())
])
);
}
}
+280
View File
@@ -0,0 +1,280 @@
use anyhow::Result;
use log::info;
use super::{NetworkEvent, NetworkResource};
use crate::models::lidarr_models::{
DeleteArtistParams, EditArtistParams, LidarrSerdeable, MetadataProfile,
};
use crate::models::servarr_models::{QualityProfile, Tag};
use crate::network::{Network, RequestMethod};
mod downloads;
mod library;
mod root_folders;
mod system;
#[cfg(test)]
#[path = "lidarr_network_tests.rs"]
mod lidarr_network_tests;
#[cfg(test)]
#[path = "lidarr_network_test_utils.rs"]
pub mod lidarr_network_test_utils;
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum LidarrEvent {
AddTag(String),
DeleteArtist(DeleteArtistParams),
DeleteTag(i64),
EditArtist(EditArtistParams),
GetArtistDetails(i64),
GetDiskSpace,
GetDownloads(u64),
GetHostConfig,
GetMetadataProfiles,
GetQualityProfiles,
GetRootFolders,
GetSecurityConfig,
GetStatus,
GetTags,
HealthCheck,
ListArtists,
SearchNewArtist(String),
ToggleArtistMonitoring(i64),
UpdateAllArtists,
}
impl NetworkResource for LidarrEvent {
fn resource(&self) -> &'static str {
match &self {
LidarrEvent::AddTag(_) | LidarrEvent::DeleteTag(_) | LidarrEvent::GetTags => "/tag",
LidarrEvent::DeleteArtist(_)
| LidarrEvent::EditArtist(_)
| LidarrEvent::GetArtistDetails(_)
| LidarrEvent::ListArtists
| LidarrEvent::ToggleArtistMonitoring(_) => "/artist",
LidarrEvent::GetDiskSpace => "/diskspace",
LidarrEvent::GetDownloads(_) => "/queue",
LidarrEvent::GetHostConfig | LidarrEvent::GetSecurityConfig => "/config/host",
LidarrEvent::UpdateAllArtists => "/command",
LidarrEvent::GetMetadataProfiles => "/metadataprofile",
LidarrEvent::GetQualityProfiles => "/qualityprofile",
LidarrEvent::GetRootFolders => "/rootfolder",
LidarrEvent::GetStatus => "/system/status",
LidarrEvent::HealthCheck => "/health",
LidarrEvent::SearchNewArtist(_) => "/artist/lookup",
}
}
}
impl From<LidarrEvent> for NetworkEvent {
fn from(lidarr_event: LidarrEvent) -> Self {
NetworkEvent::Lidarr(lidarr_event)
}
}
impl Network<'_, '_> {
pub async fn handle_lidarr_event(
&mut self,
lidarr_event: LidarrEvent,
) -> Result<LidarrSerdeable> {
match lidarr_event {
LidarrEvent::AddTag(tag) => self.add_lidarr_tag(tag).await.map(LidarrSerdeable::from),
LidarrEvent::DeleteArtist(params) => {
self.delete_artist(params).await.map(LidarrSerdeable::from)
}
LidarrEvent::DeleteTag(tag_id) => self
.delete_lidarr_tag(tag_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetArtistDetails(artist_id) => self
.get_artist_details(artist_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetDiskSpace => self.get_lidarr_diskspace().await.map(LidarrSerdeable::from),
LidarrEvent::GetDownloads(count) => self
.get_lidarr_downloads(count)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetHostConfig => self
.get_lidarr_host_config()
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetMetadataProfiles => self
.get_lidarr_metadata_profiles()
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetQualityProfiles => self
.get_lidarr_quality_profiles()
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetRootFolders => self
.get_lidarr_root_folders()
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetSecurityConfig => self
.get_lidarr_security_config()
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetStatus => self.get_lidarr_status().await.map(LidarrSerdeable::from),
LidarrEvent::GetTags => self.get_lidarr_tags().await.map(LidarrSerdeable::from),
LidarrEvent::HealthCheck => self
.get_lidarr_healthcheck()
.await
.map(LidarrSerdeable::from),
LidarrEvent::ListArtists => self.list_artists().await.map(LidarrSerdeable::from),
LidarrEvent::SearchNewArtist(query) => {
self.search_artist(query).await.map(LidarrSerdeable::from)
}
LidarrEvent::ToggleArtistMonitoring(artist_id) => self
.toggle_artist_monitoring(artist_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::UpdateAllArtists => self.update_all_artists().await.map(LidarrSerdeable::from),
LidarrEvent::EditArtist(params) => self.edit_artist(params).await.map(LidarrSerdeable::from),
}
}
pub(in crate::network::lidarr_network) async fn get_lidarr_healthcheck(&mut self) -> Result<()> {
info!("Performing Lidarr health check");
let event = LidarrEvent::HealthCheck;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), ()>(request_props, |_, _| ())
.await
}
async fn get_lidarr_metadata_profiles(&mut self) -> Result<Vec<MetadataProfile>> {
info!("Fetching Lidarr metadata profiles");
let event = LidarrEvent::GetMetadataProfiles;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<MetadataProfile>>(request_props, |metadata_profiles, mut app| {
app.data.lidarr_data.metadata_profile_map = metadata_profiles
.into_iter()
.map(|profile| (profile.id, profile.name))
.collect();
})
.await
}
async fn get_lidarr_quality_profiles(&mut self) -> Result<Vec<QualityProfile>> {
info!("Fetching Lidarr quality profiles");
let event = LidarrEvent::GetQualityProfiles;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<QualityProfile>>(request_props, |quality_profiles, mut app| {
app.data.lidarr_data.quality_profile_map = quality_profiles
.into_iter()
.map(|profile| (profile.id, profile.name))
.collect();
})
.await
}
async fn get_lidarr_tags(&mut self) -> Result<Vec<Tag>> {
info!("Fetching Lidarr tags");
let event = LidarrEvent::GetTags;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<Tag>>(request_props, |tags_vec, mut app| {
app.data.lidarr_data.tags_map = tags_vec
.into_iter()
.map(|tag| (tag.id, tag.label))
.collect();
})
.await
}
async fn add_lidarr_tag(&mut self, tag: String) -> Result<Tag> {
info!("Adding a new Lidarr tag");
let event = LidarrEvent::AddTag(String::new());
let request_props = self
.request_props_from(
event,
RequestMethod::Post,
Some(serde_json::json!({ "label": tag })),
None,
None,
)
.await;
self
.handle_request::<serde_json::Value, Tag>(request_props, |tag, mut app| {
app.data.lidarr_data.tags_map.insert(tag.id, tag.label);
})
.await
}
async fn delete_lidarr_tag(&mut self, id: i64) -> Result<()> {
info!("Deleting Lidarr tag with ID: {id}");
let event = LidarrEvent::DeleteTag(id);
let request_props = self
.request_props_from(
event,
RequestMethod::Delete,
None::<()>,
Some(format!("/{id}")),
None,
)
.await;
self
.handle_request::<(), ()>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn extract_and_add_lidarr_tag_ids_vec(
&mut self,
edit_tags: &str,
) -> Vec<i64> {
let missing_tags_vec = {
let tags_map = &self.app.lock().await.data.lidarr_data.tags_map;
edit_tags
.split(',')
.filter(|&tag| {
!tag.is_empty() && tags_map.get_by_right(tag.to_lowercase().trim()).is_none()
})
.collect::<Vec<&str>>()
};
for tag in missing_tags_vec {
self
.add_lidarr_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
.lidarr_data
.tags_map
.get_by_right(tag.to_lowercase().trim())
.unwrap()
})
.collect()
}
}
@@ -0,0 +1,39 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::LidarrSerdeable;
use crate::models::servarr_models::RootFolder;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use pretty_assertions::assert_eq;
use serde_json::json;
#[tokio::test]
async fn test_handle_get_root_folders_event() {
let root_folders_json = json!([{
"id": 1,
"path": "/music",
"accessible": true,
"freeSpace": 50000000000i64
}]);
let response: Vec<RootFolder> = serde_json::from_value(root_folders_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(root_folders_json)
.build_for(LidarrEvent::GetRootFolders)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::GetRootFolders)
.await;
mock.assert_async().await;
let LidarrSerdeable::RootFolders(root_folders) = result.unwrap() else {
panic!("Expected RootFolders");
};
assert_eq!(root_folders, response);
assert!(!app.lock().await.data.lidarr_data.root_folders.is_empty());
}
}

Some files were not shown because too many files have changed in this diff Show More