feat: Full support for deleting an artist via CLI and TUI

This commit is contained in:
2026-01-05 15:44:51 -07:00
parent bc3aeefa6e
commit 6771a0ab38
43 changed files with 1995 additions and 332 deletions
+13
View File
@@ -1,11 +1,24 @@
use crate::app::App;
use crate::app::context_clues::{ContextClue, ContextClueProvider};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::models::Route;
#[cfg(test)]
#[path = "lidarr_context_clues_tests.rs"]
mod lidarr_context_clues_tests;
pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 6] = [
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
pub(in crate::app) struct LidarrContextClueProvider;
impl ContextClueProvider for LidarrContextClueProvider {
+6 -2
View File
@@ -2,10 +2,10 @@
mod tests {
use crate::app::context_clues::ContextClueProvider;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider;
use crate::app::lidarr::lidarr_context_clues::{LidarrContextClueProvider, ARTISTS_CONTEXT_CLUES};
use crate::app::App;
use crate::models::servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, ARTISTS_CONTEXT_CLUES,
ActiveLidarrBlock,
};
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
@@ -17,6 +17,10 @@ mod tests {
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.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)
+4
View File
@@ -1,5 +1,6 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::app::App;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::network::NetworkEvent;
@@ -14,6 +15,7 @@ mod tests {
app.dispatch_by_lidarr_block(&ActiveLidarrBlock::Artists).await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
@@ -24,5 +26,7 @@ mod tests {
);
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);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ use crate::{
use super::App;
pub(in crate::app) mod lidarr_context_clues;
pub mod lidarr_context_clues;
#[cfg(test)]
#[path = "lidarr_tests.rs"]
+1 -1
View File
@@ -26,9 +26,9 @@ mod app_tests;
pub mod context_clues;
pub mod key_binding;
mod key_binding_tests;
pub mod lidarr;
pub mod radarr;
pub mod sonarr;
pub mod lidarr;
pub struct App<'a> {
navigation_stack: Vec<Route>,
@@ -455,7 +455,6 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::default().into());
// This should panic because the route is not a Sonarr route
SonarrContextClueProvider::get_context_clues(&mut app);
}
+13 -4
View File
@@ -2,18 +2,16 @@
mod tests {
use std::sync::Arc;
use clap::{CommandFactory, error::ErrorKind};
use clap::{error::ErrorKind, CommandFactory};
use mockall::predicate::eq;
use rstest::rstest;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
Cli,
app::App,
cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand},
models::{
Serdeable,
radarr_models::{
BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse,
RadarrSerdeable,
@@ -22,10 +20,12 @@ mod tests {
BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse,
SonarrSerdeable,
},
Serdeable,
},
network::{
MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent, sonarr_network::SonarrEvent,
radarr_network::RadarrEvent, sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent,
},
Cli,
};
use pretty_assertions::assert_eq;
@@ -55,6 +55,13 @@ mod tests {
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]
fn test_completions_requires_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions"]);
@@ -174,4 +181,6 @@ mod tests {
assert_ok!(&result);
}
// TODO: Implement test_cli_handler_delegates_lidarr_commands_to_the_lidarr_cli_handler
}
+80
View File
@@ -0,0 +1,80 @@
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,
},
}
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)?
}
};
Ok(result)
}
}
@@ -0,0 +1,145 @@
#[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);
}
}
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);
}
}
}
+87
View File
@@ -33,5 +33,92 @@ mod tests {
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);
}
}
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::{
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_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);
}
}
}
+13 -4
View File
@@ -2,16 +2,15 @@ use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler};
use list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use tokio::sync::Mutex;
use crate::{
app::App,
network::NetworkTrait,
};
use crate::{app::App, network::NetworkTrait};
use super::{CliCommandHandler, Command};
mod delete_command_handler;
mod list_command_handler;
#[cfg(test)]
@@ -20,6 +19,11 @@ mod lidarr_command_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrCommand {
#[command(
subcommand,
about = "Commands to delete resources from your Lidarr instance"
)]
Delete(LidarrDeleteCommand),
#[command(
subcommand,
about = "Commands to list attributes from your Lidarr instance"
@@ -54,6 +58,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, '
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrCommand::Delete(delete_command) => {
LidarrDeleteCommandHandler::with(self.app, delete_command, self.network)
.handle()
.await?
}
LidarrCommand::List(list_command) => {
LidarrListCommandHandler::with(self.app, list_command, self.network)
.handle()
@@ -0,0 +1,149 @@
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) -> crate::models::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());
}
}
+17 -2
View File
@@ -4,8 +4,11 @@ use crate::{
handlers::{KeyEventHandler, handle_clear_errors},
matches_key,
models::{
BlockSelectionState,
lidarr_models::Artist,
servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS},
servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS,
},
stateful_table::SortOption,
},
};
@@ -13,6 +16,10 @@ use crate::{
use super::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
mod delete_artist_handler;
pub(in crate::handlers::lidarr_handlers) use delete_artist_handler::DeleteArtistHandler;
#[cfg(test)]
#[path = "library_handler_tests.rs"]
mod library_handler_tests;
@@ -84,7 +91,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, '
fn handle_end(&mut self) {}
fn handle_delete(&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) {
if self.active_lidarr_block == ActiveLidarrBlock::Artists {
+5 -1
View File
@@ -1,4 +1,4 @@
use library::LibraryHandler;
use library::{DeleteArtistHandler, LibraryHandler};
use crate::{
app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock,
@@ -22,6 +22,10 @@ pub(super) struct LidarrHandler<'a, 'b> {
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b> {
fn handle(&mut self) {
match self.active_lidarr_block {
_ if DeleteArtistHandler::accepts(self.active_lidarr_block) => {
DeleteArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context)
.handle();
}
_ if LibraryHandler::accepts(self.active_lidarr_block) => {
LibraryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle();
}
+11 -3
View File
@@ -3,7 +3,7 @@ use derivative::Derivative;
use enum_display_style_derive::EnumDisplayStyle;
use serde::{Deserialize, Serialize};
use serde_json::{Number, Value};
use strum::EnumIter;
use strum::{Display, EnumIter};
use super::{HorizontallyScrollableText, Serdeable};
use crate::serde_enum_from;
@@ -45,7 +45,7 @@ pub struct Artist {
Clone,
Copy,
Debug,
strum::Display,
Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
@@ -134,7 +134,7 @@ impl Eq for DownloadRecord {}
Copy,
Debug,
EnumIter,
strum::Display,
Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
@@ -167,6 +167,14 @@ pub struct SystemStatus {
pub start_time: DateTime<Utc>,
}
#[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,
}
impl From<LidarrSerdeable> for Serdeable {
fn from(value: LidarrSerdeable) -> Serdeable {
Serdeable::Lidarr(value)
+27 -17
View File
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
use strum::EnumIter;
#[cfg(test)]
use strum::{Display, EnumString};
use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES;
use crate::models::{
Route, TabRoute, TabState,
lidarr_models::{Artist, DownloadRecord},
@@ -17,7 +17,9 @@ use crate::network::lidarr_network::LidarrEvent;
mod lidarr_data_tests;
pub struct LidarrData<'a> {
pub add_import_list_exclusion: bool,
pub artists: StatefulTable<Artist>,
pub delete_artist_files: bool,
pub disk_space_vec: Vec<DiskSpace>,
pub downloads: StatefulTable<DownloadRecord>,
pub main_tabs: TabState,
@@ -33,15 +35,18 @@ pub struct LidarrData<'a> {
}
impl LidarrData<'_> {
pub fn reset_sorting(&mut self) {
self.artists.sorting(vec![]);
pub fn reset_delete_artist_preferences(&mut self) {
self.delete_artist_files = false;
self.add_import_list_exclusion = false;
}
}
impl<'a> Default for LidarrData<'a> {
fn default() -> LidarrData<'a> {
LidarrData {
add_import_list_exclusion: false,
artists: StatefulTable::default(),
delete_artist_files: false,
disk_space_vec: Vec::new(),
downloads: StatefulTable::default(),
metadata_profile_map: BiMap::new(),
@@ -78,6 +83,8 @@ impl LidarrData<'_> {
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.quality_profile_map = BiMap::from_iter([(1i64, "Lossless".to_owned())]);
lidarr_data.metadata_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]);
lidarr_data.tags_map = BiMap::from_iter([(1i64, "usenet".to_owned())]);
@@ -93,26 +100,16 @@ impl LidarrData<'_> {
}
}
use crate::app::context_clues::ContextClue;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 5] = [
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)]
#[cfg_attr(test, derive(Display, EnumString))]
pub enum ActiveLidarrBlock {
#[default]
Artists,
ArtistsSortPrompt,
DeleteArtistPrompt,
DeleteArtistConfirmPrompt,
DeleteArtistToggleDeleteFile,
DeleteArtistToggleAddListExclusion,
SearchArtists,
SearchArtistsError,
FilterArtists,
@@ -128,6 +125,19 @@ pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 6] = [
ActiveLidarrBlock::FilterArtistsError,
];
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],
];
impl From<ActiveLidarrBlock> for Route {
fn from(active_lidarr_block: ActiveLidarrBlock) -> Route {
Route::Lidarr(active_lidarr_block, None)
@@ -2,7 +2,10 @@
mod tests {
use pretty_assertions::assert_eq;
use crate::models::{servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, Route};
use crate::models::{
servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData},
Route,
};
#[test]
fn test_from_active_lidarr_block_to_route() {
@@ -19,4 +22,18 @@ mod tests {
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);
}
}
+1 -1
View File
@@ -1,9 +1,9 @@
use crate::models::Route;
pub mod lidarr;
pub mod modals;
pub mod radarr;
pub mod sonarr;
pub mod lidarr;
#[cfg(test)]
pub(in crate::models::servarr_data) mod data_test_utils;
@@ -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
}
}
@@ -1,6 +1,6 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{Artist, LidarrSerdeable};
use crate::models::lidarr_models::{Artist, DeleteArtistParams, LidarrSerdeable};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use pretty_assertions::assert_eq;
@@ -41,4 +41,29 @@ mod tests {
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;
}
}
+33 -1
View File
@@ -1,7 +1,7 @@
use anyhow::Result;
use log::info;
use crate::models::lidarr_models::Artist;
use crate::models::lidarr_models::{Artist, DeleteArtistParams};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::Route;
use crate::network::lidarr_network::LidarrEvent;
@@ -12,6 +12,38 @@ use crate::network::{Network, RequestMethod};
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;
@@ -1,8 +1,12 @@
#[cfg(test)]
mod tests {
use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent};
use pretty_assertions::assert_str_eq;
use crate::models::lidarr_models::{LidarrSerdeable, MetadataProfile};
use crate::models::servarr_models::{QualityProfile, Tag};
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use crate::network::{lidarr_network::LidarrEvent, NetworkEvent, NetworkResource};
use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest;
use serde_json::json;
#[rstest]
#[case(LidarrEvent::GetDiskSpace, "/diskspace")]
@@ -25,4 +29,109 @@ mod tests {
NetworkEvent::from(LidarrEvent::HealthCheck)
);
}
#[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())
);
}
}
+65 -3
View File
@@ -1,10 +1,14 @@
use anyhow::Result;
use log::info;
use super::{NetworkEvent, NetworkResource};
use crate::models::lidarr_models::LidarrSerdeable;
use crate::network::Network;
use crate::models::lidarr_models::{DeleteArtistParams, 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)]
@@ -13,6 +17,7 @@ mod lidarr_network_tests;
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum LidarrEvent {
DeleteArtist(DeleteArtistParams),
GetDiskSpace,
GetDownloads(u64),
GetMetadataProfiles,
@@ -27,6 +32,7 @@ pub enum LidarrEvent {
impl NetworkResource for LidarrEvent {
fn resource(&self) -> &'static str {
match &self {
LidarrEvent::DeleteArtist(_) | LidarrEvent::ListArtists => "/artist",
LidarrEvent::GetDiskSpace => "/diskspace",
LidarrEvent::GetDownloads(_) => "/queue",
LidarrEvent::GetMetadataProfiles => "/metadataprofile",
@@ -35,7 +41,6 @@ impl NetworkResource for LidarrEvent {
LidarrEvent::GetStatus => "/system/status",
LidarrEvent::GetTags => "/tag",
LidarrEvent::HealthCheck => "/health",
LidarrEvent::ListArtists => "/artist",
}
}
}
@@ -52,6 +57,9 @@ impl Network<'_, '_> {
lidarr_event: LidarrEvent,
) -> Result<LidarrSerdeable> {
match lidarr_event {
LidarrEvent::DeleteArtist(params) => {
self.delete_artist(params).await.map(LidarrSerdeable::from)
}
LidarrEvent::GetDiskSpace => self
.get_lidarr_diskspace()
.await
@@ -84,4 +92,58 @@ impl Network<'_, '_> {
LidarrEvent::ListArtists => self.list_artists().await.map(LidarrSerdeable::from),
}
}
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
}
}
@@ -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());
}
}
@@ -0,0 +1,29 @@
use anyhow::Result;
use log::info;
use crate::models::servarr_models::RootFolder;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
#[cfg(test)]
#[path = "lidarr_root_folders_network_tests.rs"]
mod lidarr_root_folders_network_tests;
impl Network<'_, '_> {
pub(in crate::network::lidarr_network) async fn get_lidarr_root_folders(
&mut self,
) -> Result<Vec<RootFolder>> {
info!("Fetching Lidarr root folders");
let event = LidarrEvent::GetRootFolders;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<RootFolder>>(request_props, |root_folders, mut app| {
app.data.lidarr_data.root_folders.set_items(root_folders);
})
.await
}
}
@@ -1,9 +1,7 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{
DownloadsResponse, LidarrSerdeable, MetadataProfile, SystemStatus,
};
use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag};
use crate::models::lidarr_models::{LidarrSerdeable, SystemStatus};
use crate::models::servarr_models::DiskSpace;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use pretty_assertions::assert_eq;
@@ -22,111 +20,6 @@ mod tests {
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_get_diskspace_event() {
let diskspace_json = json!([{
@@ -153,71 +46,6 @@ mod tests {
assert!(!app.lock().await.data.lidarr_data.disk_space_vec.is_empty());
}
#[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());
}
#[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());
}
#[tokio::test]
async fn test_handle_get_status_event() {
let status_json = json!({
+2 -105
View File
@@ -1,8 +1,8 @@
use anyhow::Result;
use log::info;
use crate::models::lidarr_models::{DownloadsResponse, MetadataProfile, SystemStatus};
use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag};
use crate::models::lidarr_models::SystemStatus;
use crate::models::servarr_models::DiskSpace;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
@@ -24,64 +24,6 @@ impl Network<'_, '_> {
.await
}
pub(in crate::network::lidarr_network) 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
}
pub(in crate::network::lidarr_network) 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
}
pub(in crate::network::lidarr_network) 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
}
pub(in crate::network::lidarr_network) async fn get_lidarr_diskspace(
&mut self,
) -> Result<Vec<DiskSpace>> {
@@ -99,51 +41,6 @@ impl Network<'_, '_> {
.await
}
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
}
pub(in crate::network::lidarr_network) async fn get_lidarr_root_folders(
&mut self,
) -> Result<Vec<RootFolder>> {
info!("Fetching Lidarr root folders");
let event = LidarrEvent::GetRootFolders;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<RootFolder>>(request_props, |root_folders, mut app| {
app.data.lidarr_data.root_folders.set_items(root_folders);
})
.await
}
pub(in crate::network::lidarr_network) async fn get_lidarr_status(
&mut self,
) -> Result<SystemStatus> {
@@ -0,0 +1,57 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use crate::app::App;
use crate::models::Route;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_BLOCKS};
use crate::ui::DrawUi;
use crate::ui::widgets::checkbox::Checkbox;
use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt;
use crate::ui::widgets::popup::{Popup, Size};
#[cfg(test)]
#[path = "delete_artist_ui_tests.rs"]
mod delete_artist_ui_tests;
pub(in crate::ui::lidarr_ui) struct DeleteArtistUi;
impl DrawUi for DeleteArtistUi {
fn accepts(route: Route) -> bool {
let Route::Lidarr(active_lidarr_block, _) = route else {
return false;
};
DELETE_ARTIST_BLOCKS.contains(&active_lidarr_block)
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) {
if matches!(
app.get_current_route(),
Route::Lidarr(ActiveLidarrBlock::DeleteArtistPrompt, _)
) {
let selected_block = app.data.lidarr_data.selected_block.get_active_block();
let prompt = format!(
"Do you really want to delete the artist: \n{}?",
app.data.lidarr_data.artists.current_selection().artist_name.text
);
let checkboxes = vec![
Checkbox::new("Delete Artist Files")
.checked(app.data.lidarr_data.delete_artist_files)
.highlighted(selected_block == ActiveLidarrBlock::DeleteArtistToggleDeleteFile),
Checkbox::new("Add List Exclusion")
.checked(app.data.lidarr_data.add_import_list_exclusion)
.highlighted(selected_block == ActiveLidarrBlock::DeleteArtistToggleAddListExclusion),
];
let confirmation_prompt = ConfirmationPrompt::new()
.title("Delete Artist")
.prompt(&prompt)
.checkboxes(checkboxes)
.yes_no_highlighted(selected_block == ActiveLidarrBlock::DeleteArtistConfirmPrompt)
.yes_no_value(app.data.lidarr_data.prompt_confirm);
f.render_widget(
Popup::new(confirmation_prompt).size(Size::MediumPrompt),
f.area(),
);
}
}
}
@@ -0,0 +1,44 @@
#[cfg(test)]
mod tests {
use strum::IntoEnumIterator;
use crate::app::App;
use crate::models::BlockSelectionState;
use crate::models::servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS,
};
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::delete_artist_ui::DeleteArtistUi;
use crate::ui::ui_test_utils::test_utils::render_to_string_with_app;
#[test]
fn test_delete_artist_ui_accepts() {
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if DELETE_ARTIST_BLOCKS.contains(&active_lidarr_block) {
assert!(DeleteArtistUi::accepts(active_lidarr_block.into()));
} else {
assert!(!DeleteArtistUi::accepts(active_lidarr_block.into()));
}
});
}
mod snapshot_tests {
use crate::ui::ui_test_utils::test_utils::TerminalSize;
use super::*;
#[test]
fn test_delete_artist_ui_renders_delete_artist() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into());
app.data.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS);
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
DeleteArtistUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
}
}
+238 -7
View File
@@ -2,19 +2,250 @@
mod tests {
use strum::IntoEnumIterator;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS};
use crate::models::Route;
use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus};
use crate::models::servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, LIBRARY_BLOCKS,
};
use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style};
use crate::ui::styles::ManagarrStyle;
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::LibraryUi;
use pretty_assertions::assert_eq;
use ratatui::widgets::{Cell, Row};
#[test]
fn test_library_ui_accepts() {
for lidarr_block in ActiveLidarrBlock::iter() {
if LIBRARY_BLOCKS.contains(&lidarr_block) {
assert!(LibraryUi::accepts(Route::Lidarr(lidarr_block, None)));
let mut library_ui_blocks = Vec::new();
library_ui_blocks.extend(LIBRARY_BLOCKS);
library_ui_blocks.extend(DELETE_ARTIST_BLOCKS);
for active_lidarr_block in ActiveLidarrBlock::iter() {
if library_ui_blocks.contains(&active_lidarr_block) {
assert!(LibraryUi::accepts(active_lidarr_block.into()));
} else {
assert!(!LibraryUi::accepts(Route::Lidarr(lidarr_block, None)));
assert!(!LibraryUi::accepts(active_lidarr_block.into()));
}
}
}
#[test]
fn test_decorate_row_with_style_unmonitored() {
let artist = Artist::default();
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_artist_row_with_style(&artist, row.clone());
assert_eq!(style, row.unmonitored());
}
#[test]
fn test_decorate_row_with_style_downloaded_when_ended_and_all_tracks_present() {
let artist = Artist {
monitored: true,
status: ArtistStatus::Ended,
statistics: Some(ArtistStatistics {
track_file_count: 10,
total_track_count: 10,
..ArtistStatistics::default()
}),
..Artist::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_artist_row_with_style(&artist, row.clone());
assert_eq!(style, row.downloaded());
}
#[test]
fn test_decorate_row_with_style_missing_when_ended_and_tracks_are_missing() {
let artist = Artist {
monitored: true,
status: ArtistStatus::Ended,
statistics: Some(ArtistStatistics {
track_file_count: 5,
total_track_count: 10,
..ArtistStatistics::default()
}),
..Artist::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_artist_row_with_style(&artist, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_row_with_style_indeterminate_when_ended_and_no_statistics() {
let artist = Artist {
monitored: true,
status: ArtistStatus::Ended,
statistics: None,
..Artist::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_artist_row_with_style(&artist, row.clone());
assert_eq!(style, row.indeterminate());
}
#[test]
fn test_decorate_row_with_style_indeterminate_when_ended_and_total_track_count_is_zero() {
let artist = Artist {
monitored: true,
status: ArtistStatus::Ended,
statistics: Some(ArtistStatistics {
track_file_count: 0,
total_track_count: 0,
..ArtistStatistics::default()
}),
..Artist::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_artist_row_with_style(&artist, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_row_with_style_unreleased_when_continuing_and_all_tracks_present() {
let artist = Artist {
monitored: true,
status: ArtistStatus::Continuing,
statistics: Some(ArtistStatistics {
track_file_count: 10,
total_track_count: 10,
..ArtistStatistics::default()
}),
..Artist::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_artist_row_with_style(&artist, row.clone());
assert_eq!(style, row.unreleased());
}
#[test]
fn test_decorate_row_with_style_missing_when_continuing_and_tracks_are_missing() {
let artist = Artist {
monitored: true,
status: ArtistStatus::Continuing,
statistics: Some(ArtistStatistics {
track_file_count: 5,
total_track_count: 10,
..ArtistStatistics::default()
}),
..Artist::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_artist_row_with_style(&artist, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_row_with_style_indeterminate_when_continuing_and_no_statistics() {
let artist = Artist {
monitored: true,
status: ArtistStatus::Continuing,
statistics: None,
..Artist::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_artist_row_with_style(&artist, row.clone());
assert_eq!(style, row.indeterminate());
}
#[test]
fn test_decorate_row_with_style_defaults_to_indeterminate_for_deleted_status() {
let artist = Artist {
monitored: true,
status: ArtistStatus::Deleted,
..Artist::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_artist_row_with_style(&artist, row.clone());
assert_eq!(style, row.indeterminate());
}
mod snapshot_tests {
use crate::app::App;
use crate::models::BlockSelectionState;
use crate::models::servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS,
};
use rstest::rstest;
use crate::ui::lidarr_ui::library::LibraryUi;
use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app};
use crate::ui::DrawUi;
#[rstest]
fn test_library_ui_renders(
#[values(
ActiveLidarrBlock::Artists,
ActiveLidarrBlock::ArtistsSortPrompt,
ActiveLidarrBlock::SearchArtists,
ActiveLidarrBlock::SearchArtistsError,
ActiveLidarrBlock::FilterArtists,
ActiveLidarrBlock::FilterArtistsError
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(active_lidarr_block.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
LibraryUi::draw(f, app, f.area());
});
insta::assert_snapshot!(format!("lidarr_library_{active_lidarr_block}"), output);
}
#[test]
fn test_library_ui_renders_loading() {
let mut app = App::test_default_fully_populated();
app.is_loading = true;
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
LibraryUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_library_ui_renders_empty() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
LibraryUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_library_ui_renders_delete_artist_over_library() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into());
app.data.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS);
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
LibraryUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
}
}
+9 -1
View File
@@ -1,3 +1,4 @@
use delete_artist_ui::DeleteArtistUi;
use ratatui::{
Frame,
layout::{Constraint, Rect},
@@ -20,6 +21,8 @@ use crate::{
},
};
mod delete_artist_ui;
#[cfg(test)]
#[path = "library_ui_tests.rs"]
mod library_ui_tests;
@@ -29,14 +32,19 @@ pub(super) struct LibraryUi;
impl DrawUi for LibraryUi {
fn accepts(route: Route) -> bool {
if let Route::Lidarr(active_lidarr_block, _) = route {
return LIBRARY_BLOCKS.contains(&active_lidarr_block);
return DeleteArtistUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_lidarr_block);
}
false
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let route = app.get_current_route();
draw_library(f, app, area);
if DeleteArtistUi::accepts(route) {
DeleteArtistUi::draw(f, app, area);
}
}
}
@@ -0,0 +1,38 @@
---
source: src/ui/lidarr_ui/library/delete_artist_ui_tests.rs
expression: output
---
╭───────────────────── Delete Artist ─────────────────────╮
│ Do you really want to delete the artist: │
│ ? │
│ │
│ │
│ ╭───╮ │
│ Delete Artist Files: │ │ │
│ ╰───╯ │
│ ╭───╮ │
│ Add List Exclusion: │ │ │
│ ╰───╯ │
│ │
│ │
│ │
│╭────────────────────────────╮╭───────────────────────────╮│
││ Yes ││ No ││
│╰────────────────────────────╯╰───────────────────────────╯│
╰───────────────────────────────────────────────────────────╯
@@ -0,0 +1,38 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB
╭───────────────────── Delete Artist ─────────────────────╮
│ Do you really want to delete the artist: │
│ ? │
│ │
│ │
│ ╭───╮ │
│ Delete Artist Files: │ │ │
│ ╰───╯ │
│ ╭───╮ │
│ Add List Exclusion: │ │ │
│ ╰───╯ │
│ │
│ │
│ │
│╭────────────────────────────╮╭───────────────────────────╮│
││ Yes ││ No ││
│╰────────────────────────────╯╰───────────────────────────╯│
╰───────────────────────────────────────────────────────────╯
@@ -0,0 +1,5 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,8 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Loading ...
@@ -0,0 +1,7 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB
@@ -0,0 +1,42 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB
╭───────────────────────────────╮
│Name │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰───────────────────────────────╯
@@ -0,0 +1,28 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB
╭───────────────── Filter ──────────────────╮
│artist filter │
╰─────────────────────────────────────────────╯
@@ -0,0 +1,31 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB
╭─────────────── Error ───────────────╮
│The given filter produced empty results│
│ │
╰───────────────────────────────────────╯
@@ -0,0 +1,28 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB
╭───────────────── Search ──────────────────╮
│artist search │
╰─────────────────────────────────────────────╯
@@ -0,0 +1,31 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB
╭─────────────── Error ───────────────╮
│ No items found matching search │
│ │
╰───────────────────────────────────────╯