From 4b7185fbb01184a8bc813058eeafdd90f3476f71 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 16:37:46 -0700 Subject: [PATCH] feat(handler): Blocklist handler support --- .../blocklist/blocklist_handler_tests.rs | 947 ++++++++++++++++++ src/handlers/sonarr_handlers/blocklist/mod.rs | 279 ++++++ src/handlers/sonarr_handlers/mod.rs | 1 + src/models/servarr_data/sonarr/sonarr_data.rs | 8 + .../servarr_data/sonarr/sonarr_data_tests.rs | 14 +- src/models/sonarr_models.rs | 2 + src/network/sonarr_network.rs | 24 +- src/network/sonarr_network_tests.rs | 13 + 8 files changed, 1284 insertions(+), 4 deletions(-) create mode 100644 src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs create mode 100644 src/handlers/sonarr_handlers/blocklist/mod.rs diff --git a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs new file mode 100644 index 0000000..6bf173b --- /dev/null +++ b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs @@ -0,0 +1,947 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::blocklist::{blocklist_sorting_options, BlocklistHandler}; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; + use crate::models::sonarr_models::BlocklistItem; + use crate::models::stateful_table::SortOption; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + + use crate::models::sonarr_models::BlocklistItem; + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + + use super::*; + + test_iterable_scroll!( + test_blocklist_scroll, + BlocklistHandler, + sonarr_data, + blocklist, + simple_stateful_iterable_vec!(BlocklistItem, String, source_title), + ActiveSonarrBlock::Blocklist, + None, + source_title, + to_string + ); + + #[rstest] + fn test_blocklist_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = true; + app + .data + .sonarr_data + .blocklist + .set_items(simple_stateful_iterable_vec!( + BlocklistItem, + String, + source_title + )); + + BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .blocklist + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + + BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .blocklist + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + } + + #[rstest] + fn test_blocklist_sort_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let blocklist_field_vec = sort_options(); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.sorting(sort_options()); + + if key == Key::Up { + for i in (0..blocklist_field_vec.len()).rev() { + BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::BlocklistSortPrompt, None) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .blocklist + .sort + .as_ref() + .unwrap() + .current_selection(), + &blocklist_field_vec[i] + ); + } + } else { + for i in 0..blocklist_field_vec.len() { + BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::BlocklistSortPrompt, None) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .blocklist + .sort + .as_ref() + .unwrap() + .current_selection(), + &blocklist_field_vec[(i + 1) % blocklist_field_vec.len()] + ); + } + } + } + } + + mod test_handle_home_end { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::models::sonarr_models::BlocklistItem; + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + + test_iterable_home_and_end!( + test_blocklist_home_and_end, + BlocklistHandler, + sonarr_data, + blocklist, + extended_stateful_iterable_vec!(BlocklistItem, String, source_title), + ActiveSonarrBlock::Blocklist, + None, + source_title, + to_string + ); + + #[test] + fn test_blocklist_home_and_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = true; + app + .data + .sonarr_data + .blocklist + .set_items(extended_stateful_iterable_vec!( + BlocklistItem, + String, + source_title + )); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .blocklist + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .blocklist + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + } + + #[test] + fn test_blocklist_sort_home_end() { + let blocklist_field_vec = sort_options(); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.sorting(sort_options()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::BlocklistSortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .blocklist + .sort + .as_ref() + .unwrap() + .current_selection(), + &blocklist_field_vec[blocklist_field_vec.len() - 1] + ); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::BlocklistSortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .blocklist + .sort + .as_ref() + .unwrap() + .current_selection(), + &blocklist_field_vec[0] + ); + } + } + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_blocklist_item_prompt() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::DeleteBlocklistItemPrompt.into() + ); + } + + #[test] + fn test_delete_blocklist_item_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_blocklist_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(2); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::Downloads.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Downloads.into()); + } + + #[rstest] + fn test_blocklist_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(2); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::History.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + } + + #[rstest] + fn test_blocklist_left_right_prompt_toggle( + #[values( + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + BlocklistHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_blocklist_submit() { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::BlocklistItemDetails.into() + ); + } + + #[test] + fn test_blocklist_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[rstest] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + SonarrEvent::DeleteBlocklistItem(None) + )] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt, + SonarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm_submit( + #[case] base_route: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + ) { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + assert_eq!(app.get_current_route(), base_route.into()); + } + + #[rstest] + fn test_blocklist_prompt_decline_submit( + #[values( + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt + )] + prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[test] + fn test_blocklist_sort_prompt_submit() { + let mut app = App::default(); + app.data.sonarr_data.blocklist.sort_asc = true; + app.data.sonarr_data.blocklist.sorting(sort_options()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); + + let mut expected_vec = blocklist_vec(); + expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); + expected_vec.reverse(); + + BlocklistHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::BlocklistSortPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert_eq!(app.data.sonarr_data.blocklist.items, expected_vec); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::handlers::sonarr_handlers::downloads::DownloadsHandler; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::DeleteBlocklistItemPrompt + )] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt + )] + fn test_blocklist_prompt_blocks_esc( + #[case] base_block: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(base_block.into()); + app.push_navigation_stack(prompt_block.into()); + app.data.sonarr_data.prompt_confirm = true; + + BlocklistHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert_eq!(app.get_current_route(), base_block.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[test] + fn test_esc_blocklist_item_details() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveSonarrBlock::BlocklistItemDetails.into()); + + BlocklistHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::BlocklistItemDetails, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[test] + fn test_blocklist_sort_prompt_block_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); + + BlocklistHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::BlocklistSortPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + DownloadsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert!(app.error.text.is_empty()); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + #[test] + fn test_refresh_blocklist_key() { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_blocklist_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_clear_blocklist_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::BlocklistClearAllItemsPrompt.into() + ); + } + + #[test] + fn test_clear_blocklist_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[test] + fn test_sort_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::BlocklistSortPrompt.into() + ); + assert_eq!( + app.data.sonarr_data.blocklist.sort.as_ref().unwrap().items, + blocklist_sorting_options() + ); + assert!(!app.data.sonarr_data.blocklist.sort_asc); + } + + #[test] + fn test_sort_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert!(app.data.sonarr_data.blocklist.sort.is_none()); + assert!(!app.data.sonarr_data.blocklist.sort_asc); + } + + #[rstest] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + SonarrEvent::DeleteBlocklistItem(None) + )] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt, + SonarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm( + #[case] base_route: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + ) { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + assert_eq!(app.get_current_route(), base_route.into()); + } + } + + #[test] + fn test_blocklist_sorting_options_series_title() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.series_title + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.series_title + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[0].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Series Title"); + } + + #[test] + fn test_blocklist_sorting_options_source_title() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[1].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Source Title"); + } + + #[test] + fn test_blocklist_sorting_options_language() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.language + .name + .to_lowercase() + .cmp(&b.language.name.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[2].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Language"); + } + + #[test] + fn test_blocklist_sorting_options_quality() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[3].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + #[test] + fn test_blocklist_sorting_options_date() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = + |a, b| a.date.cmp(&b.date); + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[4].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Date"); + } + + #[test] + fn test_blocklist_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if BLOCKLIST_BLOCKS.contains(&active_sonarr_block) { + assert!(BlocklistHandler::accepts(active_sonarr_block)); + } else { + assert!(!BlocklistHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_blocklist_handler_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = true; + + let handler = BlocklistHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_not_ready_when_blocklist_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = false; + + let handler = BlocklistHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_ready_when_not_loading_and_blocklist_is_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = false; + app + .data + .sonarr_data + .blocklist + .set_items(vec![BlocklistItem::default()]); + + let handler = BlocklistHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ); + + assert!(handler.is_ready()); + } + + fn blocklist_vec() -> Vec { + vec![ + BlocklistItem { + id: 3, + source_title: "test 1".to_owned(), + language: Language { + id: 1, + name: "telgu".to_owned(), + }, + quality: QualityWrapper { + quality: Quality { + name: "HD - 1080p".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + series_title: Some("test 3".into()), + ..BlocklistItem::default() + }, + BlocklistItem { + id: 2, + source_title: "test 2".to_owned(), + language: Language { + id: 3, + name: "chinese".to_owned(), + }, + quality: QualityWrapper { + quality: Quality { + name: "SD - 720p".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + series_title: Some("test 2".into()), + ..BlocklistItem::default() + }, + BlocklistItem { + id: 1, + source_title: "test 3".to_owned(), + language: Language { + id: 1, + name: "english".to_owned(), + }, + quality: QualityWrapper { + quality: Quality { + name: "HD - 1080p".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + series_title: None, + ..BlocklistItem::default() + }, + ] + } + + fn sort_options() -> Vec> { + vec![SortOption { + name: "Test 1", + cmp_fn: Some(|a, b| { + b.source_title + .to_lowercase() + .cmp(&a.source_title.to_lowercase()) + }), + }] + } +} diff --git a/src/handlers/sonarr_handlers/blocklist/mod.rs b/src/handlers/sonarr_handlers/blocklist/mod.rs new file mode 100644 index 0000000..46d92ad --- /dev/null +++ b/src/handlers/sonarr_handlers/blocklist/mod.rs @@ -0,0 +1,279 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; +use crate::models::sonarr_models::BlocklistItem; +use crate::models::stateful_table::SortOption; +use crate::models::Scrollable; +use crate::network::sonarr_network::SonarrEvent; + +#[cfg(test)] +#[path = "blocklist_handler_tests.rs"] +mod blocklist_handler_tests; + +pub(super) struct BlocklistHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + BLOCKLIST_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + context: Option, + ) -> Self { + BlocklistHandler { + key, + app, + active_sonarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.sonarr_data.blocklist.is_empty() + } + + fn handle_scroll_up(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_up(), + ActiveSonarrBlock::BlocklistSortPrompt => self + .app + .data + .sonarr_data + .blocklist + .sort + .as_mut() + .unwrap() + .scroll_up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_down(), + ActiveSonarrBlock::BlocklistSortPrompt => self + .app + .data + .sonarr_data + .blocklist + .sort + .as_mut() + .unwrap() + .scroll_down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_to_top(), + ActiveSonarrBlock::BlocklistSortPrompt => self + .app + .data + .sonarr_data + .blocklist + .sort + .as_mut() + .unwrap() + .scroll_to_top(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_to_bottom(), + ActiveSonarrBlock::BlocklistSortPrompt => self + .app + .data + .sonarr_data + .blocklist + .sort + .as_mut() + .unwrap() + .scroll_to_bottom(), + _ => (), + } + } + + fn handle_delete(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Blocklist { + self + .app + .push_navigation_stack(ActiveSonarrBlock::DeleteBlocklistItemPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => handle_change_tab_left_right_keys(self.app, self.key), + ActiveSonarrBlock::DeleteBlocklistItemPrompt + | ActiveSonarrBlock::BlocklistClearAllItemsPrompt => handle_prompt_toggle(self.app, self.key), + _ => {} + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::DeleteBlocklistItemPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteBlocklistItem(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::BlocklistClearAllItemsPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::ClearBlocklist); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::BlocklistSortPrompt => { + self + .app + .data + .sonarr_data + .blocklist + .items + .sort_by(|a, b| a.id.cmp(&b.id)); + self.app.data.sonarr_data.blocklist.apply_sorting(); + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::Blocklist => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::BlocklistItemDetails.into()); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::DeleteBlocklistItemPrompt + | ActiveSonarrBlock::BlocklistClearAllItemsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + ActiveSonarrBlock::BlocklistItemDetails | ActiveSonarrBlock::BlocklistSortPrompt => { + self.app.pop_navigation_stack(); + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => match self.key { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { + self.app.should_refresh = true; + } + _ if key == DEFAULT_KEYBINDINGS.clear.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::BlocklistClearAllItemsPrompt.into()); + } + _ if key == DEFAULT_KEYBINDINGS.sort.key => { + self + .app + .data + .sonarr_data + .blocklist + .sorting(blocklist_sorting_options()); + self + .app + .push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); + } + _ => (), + }, + ActiveSonarrBlock::DeleteBlocklistItemPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteBlocklistItem(None)); + + self.app.pop_navigation_stack(); + } + } + ActiveSonarrBlock::BlocklistClearAllItemsPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::ClearBlocklist); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } +} + +fn blocklist_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Series Title", + cmp_fn: Some(|a, b| { + a.series_title + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.series_title + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }), + }, + SortOption { + name: "Source Title", + cmp_fn: Some(|a, b| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }), + }, + SortOption { + name: "Language", + cmp_fn: Some(|a, b| { + a.language + .name + .to_lowercase() + .cmp(&b.language.name.to_lowercase()) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }), + }, + SortOption { + name: "Date", + cmp_fn: Some(|a, b| a.date.cmp(&b.date)), + }, + ] +} diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index c3a26f5..fd0b895 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -9,6 +9,7 @@ use crate::{ use super::KeyEventHandler; +mod blocklist; mod downloads; mod library; diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 335b54a..1d277c5 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -322,6 +322,14 @@ pub const ADD_SERIES_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ &[ActiveSonarrBlock::AddSeriesConfirmPrompt], ]; +pub static BLOCKLIST_BLOCKS: [ActiveSonarrBlock; 5] = [ + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::BlocklistItemDetails, + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt, + ActiveSonarrBlock::BlocklistSortPrompt, +]; + pub static EDIT_SERIES_BLOCKS: [ActiveSonarrBlock; 9] = [ ActiveSonarrBlock::EditSeriesPrompt, ActiveSonarrBlock::EditSeriesConfirmPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 0f60183..e851c9b 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -202,8 +202,8 @@ mod tests { mod active_sonarr_block_tests { use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, DELETE_SERIES_BLOCKS, - DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, + ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, BLOCKLIST_BLOCKS, + DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, LIBRARY_BLOCKS, }; @@ -276,6 +276,16 @@ mod tests { assert_eq!(add_series_block_iter.next(), None); } + #[test] + fn test_blocklist_blocks_contents() { + assert_eq!(BLOCKLIST_BLOCKS.len(), 5); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::Blocklist)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::BlocklistItemDetails)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::DeleteBlocklistItemPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::BlocklistClearAllItemsPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::BlocklistSortPrompt)); + } + #[test] fn test_edit_movie_blocks_contents() { assert_eq!(EDIT_SERIES_BLOCKS.len(), 9); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index d98d9a7..1e8bcb9 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -78,6 +78,8 @@ pub struct BlocklistItem { pub id: i64, #[serde(deserialize_with = "super::from_i64")] pub series_id: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub series_title: Option, pub episode_ids: Vec, pub source_title: String, pub language: Language, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index d87fbaa..e55160b 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -19,7 +19,7 @@ use crate::{ LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ - AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistResponse, + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DeleteSeriesParams, DownloadRecord, DownloadsResponse, EditSeriesParams, Episode, IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, @@ -1303,7 +1303,27 @@ impl<'a, 'b> Network<'a, 'b> { app.get_current_route(), Route::Sonarr(ActiveSonarrBlock::BlocklistSortPrompt, _) ) { - let mut blocklist_vec = blocklist_resp.records; + let mut blocklist_vec: Vec = blocklist_resp + .records + .into_iter() + .map(|item| { + if let Some(series) = app + .data + .sonarr_data + .series + .items + .iter() + .find(|it| it.id == item.series_id) + { + BlocklistItem { + series_title: Some(series.title.text.clone()), + ..item + } + } else { + item + } + }) + .collect(); blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); app.data.sonarr_data.blocklist.set_items(blocklist_vec); app.data.sonarr_data.blocklist.apply_sorting_toggle(false); diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index d417562..0493a0f 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -1956,6 +1956,7 @@ mod test { BlocklistItem { id: 123, series_id: 1007, + series_title: Some("Z Series".into()), source_title: "z series".into(), episode_ids: vec![Number::from(42020)], ..blocklist_item() @@ -1978,6 +1979,17 @@ mod test { None, ) .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1007, + title: "Z Series".into(), + ..series() + }]); app_arc.lock().await.data.sonarr_data.blocklist.sort_asc = true; if use_custom_sorting { let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { @@ -6682,6 +6694,7 @@ mod test { BlocklistItem { id: 1, series_id: 1, + series_title: None, episode_ids: vec![Number::from(1)], source_title: "Test Source Title".to_owned(), language: language(),