Compare commits

...
10 Commits
Author SHA1 Message Date
Dark-Alex-17 1d936c5cb5 fix(radarr): serialize the credit type under the same key it is read from
Radarr sends and accepts the credit type as "type" in both directions,
but the field was only renamed for deserialization, so serializing a
Credit emitted "creditType" and a to_value round trip could not be read
back. Credit is only deserialized in production, so this affects tests
and any future serialization of the record, not the live API traffic.
2026-09-17 15:49:12 -06:00
Dark-Alex-17 7b6d595397 fix(radarr): model the download status as a DownloadStatus enum
Radarr was the only tree still holding DownloadRecord.status as a bare
String and comparing it against literals. It now carries its own
DownloadStatus enum, shaped exactly like the Sonarr, Lidarr and Readarr
copies, and is guarded with from_json_or_default so an unrecognised
status degrades to Unknown instead of failing the whole queue fetch.

The row-style decorator and get_movie_status compare against the enum
variants. The status cell is never rendered in the Radarr tree, so no
snapshot changes; the CLI JSON output is unchanged for every real value
since the enum serializes to the same camelCase strings.
2026-09-17 15:46:49 -06:00
Dark-Alex-17 25da032f02 test(lidarr): give the download record fixture distinct album and artist ids
The shared download record held id, album id, and artist id all equal to
1, so reading the wrong one of the three could not fail any test that
uses the fixture. Move artist id to 3 and id to 9, keeping album id at 1
so the fully populated album view still shows its track as downloading,
and update the three handler assertions that named the old id.
2026-09-17 15:36:27 -06:00
Dark-Alex-17 e9c323c4e8 test(sonarr): cover the episode row style branches
decorate_with_row_style had no unit tests, so every one of its seven
return sites could be swapped for another style without a failure. Add
one test per branch, including the ordering cases where the download
match precedes the monitored guard and the monitored guard precedes the
air date check, using a local download fixture with distinct episode,
other-episode, and download ids.
2026-09-17 15:30:16 -06:00
Dark-Alex-17 289abbd8eb test(readarr): populate the book fixture with statistics
The shared Book fixture left statistics as None, so every author details
snapshot rendered the missing-statistics N/A cells and the populated
branch of the Files and Size columns was only exercised by one test.
Give the fixture distinct, non-default counts and a size that formats to
a value different from the dedicated statistics test.
2026-09-17 15:19:16 -06:00
Dark-Alex-17 a68de41025 fix: fall back to the default when a servarr returns an unknown enum value
A Servarr build can emit an enum value this app has no name for, either
as a string it does not recognise or as a bare integer for a value the
build itself cannot name. Until now any such value in a response failed
the whole payload, so one unexpected artist status, download status,
task name, series type, minimum availability, credit type, or security
setting emptied the entire view with "Failed to parse response!".

Every enum-typed field on a struct the app only ever deserializes and
displays now uses from_json_or_default, so an unrecognised value
degrades to that enum's default and the rest of the record is kept.
Known values still map exactly as before and serialization is untouched.

Fields on request bodies (Add*Body, Add*Options, Edit*Params, the root
folder bodies) are deliberately left alone: those structs are only ever
serialized, so the attribute would be meaningless there. No candidate
struct is round-tripped: every edit and toggle in the app re-fetches
the raw JSON value and patches it by key, so the fallback can never be
written back to a server as a real setting.

The one Option field, the security config's authentication_required,
also carries serde(default), because deserialize_with on an Option turns
a missing key into a hard error. An unknown value there yields None.

Verified by mutation. Removing the attribute from one field per tree
fails exactly that field's test and nothing else. Dropping default from
the Option field fails only the missing-key test with "missing field
authenticationRequired"; dropping deserialize_with from it fails only
the unknown-value test. All 21 new fallback tests fail against the
unguarded code with "invalid type: integer 2, expected string or map".
2026-09-17 15:08:52 -06:00
Dark-Alex-17 9d0973352f feat(lidarr): highlight tracks whose album is downloading or awaiting import
Lidarr queues whole albums rather than individual tracks, so a track row
is styled from the queue entry whose albumId matches the track's albumId,
mirroring the Sonarr episode row logic. Tracks that already have a file
keep the downloaded style regardless of the queue.
2026-09-17 14:50:27 -06:00
Dark-Alex-17 76c490f45b fix: show N/A instead of zeros when a season, album or book has no statistics
The season, album and book tables rendered 0/0 and 0.00 GB whenever the
API omitted the statistics object for a row, presenting fabricated counts
as real. The row colour already treated that case as indeterminate; the
cell text now does too, rendering N/A for both cells, matching what these
tables already do for a missing release date.

Sonarr's season row is restructured from a destructure-with-default to the
same Option-aware map_or_else shape lidarr and readarr use.

The readarr test fixture book has no statistics, so its existing author
details snapshots change from the fabricated zeros to N/A on exactly that
row.
2026-09-17 14:42:54 -06:00
Dark-Alex-17 9b4b3003b5 test: give the string input and table filter property tests real assertions 2026-09-17 14:33:16 -06:00
Dark-Alex-17 d678f5777a test: cover the status style branches for track and episode details 2026-09-17 14:22:47 -06:00
44 changed files with 1525 additions and 110 deletions

No files matched your search

@@ -5,3 +5,4 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 56330c025ad79db641d0eb9f429ab74e95822e1fb015b58f0e158ea674cd42a1 # shrinks to list_size = 1, page_ops = 1
cc 29df5ff471eb388b0ffde573a959a539e4cf32acd9e1cea5187bba2268dd57f7 # shrinks to list_size = 1, filter_term = "𑛀"
+52 -19
View File
@@ -3,13 +3,16 @@ mod property_tests {
use proptest::prelude::*;
use crate::app::App;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::event::Key;
use crate::handlers::handle_events;
use crate::handlers::handler_test_utils::test_utils::proptest_helpers::{
list_size, text_input_string,
};
use crate::models::radarr_models::Movie;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::stateful_table::StatefulTable;
use crate::models::{Paginated, Scrollable};
use crate::models::{Paginated, Scrollable, strip_non_search_characters};
proptest! {
#[test]
@@ -104,13 +107,29 @@ mod property_tests {
#[test]
fn test_string_input_safety(input in text_input_string()) {
let _lowercase = input.to_lowercase();
let _uppercase = input.to_uppercase();
let _trimmed = input.trim();
let _len = input.len();
let _chars: Vec<char> = input.chars().collect();
let mut app = App::test_default();
app.data.radarr_data.movies.set_items(vec![Movie::default()]);
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
handle_events(DEFAULT_KEYBINDINGS.filter.key, &mut app);
prop_assert!(true);
for character in input.chars() {
handle_events(Key::Char(character), &mut app);
}
prop_assert_eq!(app.get_current_route(), ActiveRadarrBlock::FilterMovies.into());
prop_assert_eq!(
app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(),
input.as_str()
);
for _ in 0..input.chars().count() {
handle_events(DEFAULT_KEYBINDINGS.backspace.key, &mut app);
}
prop_assert_eq!(
app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(),
""
);
}
#[test]
@@ -177,22 +196,36 @@ mod property_tests {
..Movie::default()
})
.collect();
table.set_items(movies.clone());
let original_size = table.items.len();
table.filter = Some(filter_term.clone().into());
let scrubbed_filter = strip_non_search_characters(&filter_term);
let matches_filter = |movie: &Movie| {
strip_non_search_characters(&movie.title.text).contains(&scrubbed_filter)
};
if !filter_term.is_empty() {
let filtered: Vec<Movie> = movies.into_iter()
.filter(|m| m.title.text.to_lowercase().contains(&filter_term.to_lowercase()))
.collect();
table.set_items(filtered);
let has_match = table.apply_filter(|movie| &movie.title.text);
prop_assert_eq!(&table.items, &movies);
prop_assert!(table.filter.is_none());
if has_match {
let filtered_items = table.filtered_items.as_ref().unwrap();
prop_assert!(!filtered_items.is_empty());
prop_assert!(filtered_items.len() <= movies.len());
for movie in filtered_items {
prop_assert!(matches_filter(movie));
}
prop_assert!(table.items.len() <= original_size);
if !table.items.is_empty() {
let current = table.current_selection();
prop_assert!(current.id >= 0);
for movie in movies.iter().filter(|movie| !filtered_items.contains(movie)) {
prop_assert!(!matches_filter(movie));
}
} else {
prop_assert!(table.filtered_items.is_none());
prop_assert!(
filter_term.is_empty() || movies.iter().all(|movie| !matches_filter(movie))
);
}
}
}
@@ -141,7 +141,7 @@ mod tests {
#[case(
ActiveLidarrBlock::Downloads,
ActiveLidarrBlock::DeleteDownloadPrompt,
LidarrEvent::DeleteDownload(1)
LidarrEvent::DeleteDownload(9)
)]
#[case(
ActiveLidarrBlock::Downloads,
@@ -338,7 +338,7 @@ mod tests {
#[case(
ActiveLidarrBlock::Downloads,
ActiveLidarrBlock::DeleteDownloadPrompt,
LidarrEvent::DeleteDownload(1)
LidarrEvent::DeleteDownload(9)
)]
#[case(
ActiveLidarrBlock::Downloads,
@@ -423,7 +423,7 @@ mod tests {
)
.extract_download_id();
assert_eq!(download_id, 1);
assert_eq!(download_id, 9);
}
#[test]
@@ -4,8 +4,8 @@ pub(in crate::handlers::radarr_handlers) mod utils {
use crate::models::HorizontallyScrollableText;
use crate::models::radarr_models::{
AddMovieBody, AddMovieOptions, AddMovieSearchResult, Collection, CollectionMovie,
DownloadRecord, IndexerSettings, MediaInfo, MinimumAvailability, Movie, MovieCollection,
MovieFile, MovieMonitor, RadarrRelease, Rating, RatingsList,
DownloadRecord, DownloadStatus, IndexerSettings, MediaInfo, MinimumAvailability, Movie,
MovieCollection, MovieFile, MovieMonitor, RadarrRelease, Rating, RatingsList,
};
use crate::models::servarr_models::{
Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
@@ -407,7 +407,7 @@ pub(in crate::handlers::radarr_handlers) mod utils {
pub fn download_record() -> DownloadRecord {
DownloadRecord {
title: "Test Download Title".to_owned(),
status: "downloading".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
movie_id: 1,
size: 3543348019,
+5
View File
@@ -27,6 +27,7 @@ pub struct Artist {
pub id: i64,
pub artist_name: HorizontallyScrollableText,
pub foreign_artist_id: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: ArtistStatus,
pub overview: Option<String>,
pub artist_type: Option<String>,
@@ -38,6 +39,7 @@ pub struct Artist {
#[serde(deserialize_with = "super::from_i64")]
pub metadata_profile_id: i64,
pub monitored: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub monitor_new_items: NewItemMonitorType,
pub genres: Vec<String>,
pub tags: Vec<Number>,
@@ -161,6 +163,7 @@ pub enum MonitorType {
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
pub title: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
@@ -219,6 +222,7 @@ pub struct DownloadsResponse {
pub struct AddArtistSearchResult {
pub foreign_artist_id: String,
pub artist_name: HorizontallyScrollableText,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: ArtistStatus,
pub overview: Option<String>,
pub artist_type: Option<String>,
@@ -414,6 +418,7 @@ pub struct LidarrHistoryItem {
#[serde(rename_all = "camelCase")]
pub struct LidarrTask {
pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: LidarrTaskName,
#[serde(deserialize_with = "super::from_i64")]
pub interval: i64,
+108 -2
View File
@@ -2,13 +2,14 @@
mod tests {
use chrono::Utc;
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::lidarr_models::{
AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord,
DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem,
LidarrHistoryWrapper, LidarrRelease, LidarrTask, MediaInfo, Member, MonitorType,
NewItemMonitorType, Track, TrackFile,
LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member,
MonitorType, NewItemMonitorType, Track, TrackFile,
};
use crate::models::servarr_models::{
DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse,
@@ -19,6 +20,9 @@ mod tests {
Serdeable,
lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings},
};
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON, download_record, task,
};
#[test]
fn test_artist_status_default() {
@@ -946,6 +950,108 @@ mod tests {
assert_none!(&search_result.ratings);
}
#[test]
fn test_artist_status_deserialization_falls_back_to_default() {
let expected = Artist {
status: ArtistStatus::default(),
..serde_json::from_str(ARTIST_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_artist_monitor_new_items_deserialization_falls_back_to_default() {
let expected = Artist {
monitor_new_items: NewItemMonitorType::default(),
..serde_json::from_str(ARTIST_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "monitorNewItems", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "monitorNewItems", json!("notAThing")),
expected
);
}
#[test]
fn test_add_artist_search_result_status_deserialization_falls_back_to_default() {
let expected = AddArtistSearchResult {
status: ArtistStatus::default(),
..serde_json::from_str(ADD_ARTIST_SEARCH_RESULT_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<AddArtistSearchResult>(
ADD_ARTIST_SEARCH_RESULT_JSON,
"status",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<AddArtistSearchResult>(
ADD_ARTIST_SEARCH_RESULT_JSON,
"status",
json!("notAThing")
),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_lidarr_task_task_name_deserialization_falls_back_to_default() {
let task_json = serde_json::to_string(&task()).unwrap();
let expected = LidarrTask {
task_name: LidarrTaskName::default(),
..task()
};
assert_eq!(
deserialize_with_field::<LidarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<LidarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> LidarrHistoryEventType {
let history_item_json = json!({
"id": 1,
+36 -2
View File
@@ -98,6 +98,7 @@ pub struct Collection {
pub search_on_add: bool,
pub monitored: bool,
pub overview: Option<String>,
#[serde(deserialize_with = "super::from_json_or_default")]
pub minimum_availability: MinimumAvailability,
#[serde(deserialize_with = "super::from_i64")]
pub quality_profile_id: i64,
@@ -126,7 +127,7 @@ pub struct Credit {
pub character: Option<String>,
pub department: Option<String>,
pub job: Option<String>,
#[serde(rename(deserialize = "type"))]
#[serde(rename = "type", deserialize_with = "super::from_json_or_default")]
pub credit_type: CreditType,
}
@@ -150,7 +151,8 @@ pub struct DeleteMovieParams {
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
pub title: String,
pub status: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
#[serde(deserialize_with = "super::from_i64")]
@@ -165,6 +167,36 @@ pub struct DownloadRecord {
pub download_client: String,
}
#[derive(
Serialize,
Deserialize,
Default,
PartialEq,
Eq,
Clone,
Copy,
Debug,
EnumIter,
Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum DownloadStatus {
#[default]
Unknown,
Queued,
Paused,
Downloading,
Completed,
Failed,
Warning,
Delay,
#[display_style(name = "Download Client Unavailable")]
DownloadClientUnavailable,
Fallback,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DownloadsResponse {
@@ -316,6 +348,7 @@ pub struct Movie {
pub tmdb_id: i64,
#[serde(deserialize_with = "super::from_i64")]
pub quality_profile_id: i64,
#[serde(deserialize_with = "super::from_json_or_default")]
pub minimum_availability: MinimumAvailability,
pub certification: Option<String>,
pub tags: Vec<Number>,
@@ -469,6 +502,7 @@ pub struct RadarrHistoryItem {
#[serde(rename_all = "camelCase")]
pub struct RadarrTask {
pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: RadarrTaskName,
#[serde(deserialize_with = "super::from_i64")]
pub interval: i64,
+156 -6
View File
@@ -1,6 +1,7 @@
#[cfg(test)]
mod tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::radarr_models::{
@@ -9,15 +10,18 @@ mod tests {
use crate::models::{
Serdeable,
radarr_models::{
AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace,
DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult,
MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease,
RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update,
AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, CreditType,
DiskSpace, DownloadRecord, DownloadStatus, DownloadsResponse, Indexer, IndexerSettings,
IndexerTestResult, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor,
QualityProfile, RadarrRelease, RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update,
},
servarr_models::{
HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, SystemStatus,
},
};
use crate::network::radarr_network::radarr_network_test_utils::test_utils::{
MOVIE_JSON, collection, crew_credit, download_record, task,
};
#[test]
fn test_task_name_display() {
@@ -66,6 +70,40 @@ mod tests {
assert_str_eq!(MovieMonitor::None.to_display_str(), "None");
}
#[test]
fn test_download_status_display() {
assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown");
assert_str_eq!(DownloadStatus::Queued.to_string(), "queued");
assert_str_eq!(DownloadStatus::Paused.to_string(), "paused");
assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading");
assert_str_eq!(DownloadStatus::Completed.to_string(), "completed");
assert_str_eq!(DownloadStatus::Failed.to_string(), "failed");
assert_str_eq!(DownloadStatus::Warning.to_string(), "warning");
assert_str_eq!(DownloadStatus::Delay.to_string(), "delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_string(),
"downloadClientUnavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback");
}
#[test]
fn test_download_status_to_display_str() {
assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown");
assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued");
assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused");
assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading");
assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed");
assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed");
assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning");
assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_display_str(),
"Download Client Unavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback");
}
#[test]
fn test_radarr_history_event_type_display() {
assert_str_eq!(RadarrHistoryEventType::Unknown.to_string(), "unknown");
@@ -222,11 +260,23 @@ mod tests {
);
}
#[test]
fn test_credit_serializes_credit_type_under_the_type_key() {
let credit_json = serde_json::to_value(crew_credit()).unwrap();
assert_eq!(credit_json["type"], json!("crew"));
assert_none!(credit_json.get("creditType"));
assert_eq!(
serde_json::from_value::<Credit>(credit_json).unwrap(),
crew_credit()
);
}
#[test]
fn test_download_record_default_indexer_value() {
let json = r#"{
"title": "test",
"status": "test",
"status": "downloading",
"id": 0,
"movieId": 0,
"size": 0,
@@ -235,7 +285,7 @@ mod tests {
}"#;
let expected_record = DownloadRecord {
title: "test".to_owned(),
status: "test".to_owned(),
status: DownloadStatus::Downloading,
id: 0,
movie_id: 0,
size: 0,
@@ -614,6 +664,106 @@ mod tests {
);
}
#[test]
fn test_movie_minimum_availability_deserialization_falls_back_to_default() {
let expected = Movie {
minimum_availability: MinimumAvailability::default(),
..serde_json::from_str(MOVIE_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Movie>(MOVIE_JSON, "minimumAvailability", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Movie>(MOVIE_JSON, "minimumAvailability", json!("notAThing")),
expected
);
}
#[test]
fn test_collection_minimum_availability_deserialization_falls_back_to_default() {
let collection_json = serde_json::to_string(&collection()).unwrap();
let expected = Collection {
minimum_availability: MinimumAvailability::default(),
..collection()
};
assert_eq!(
deserialize_with_field::<Collection>(&collection_json, "minimumAvailability", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Collection>(
&collection_json,
"minimumAvailability",
json!("notAThing")
),
expected
);
}
#[test]
fn test_credit_credit_type_deserialization_falls_back_to_default() {
let credit_json = serde_json::to_string(&crew_credit()).unwrap();
let expected = Credit {
credit_type: CreditType::default(),
..crew_credit()
};
assert_eq!(
deserialize_with_field::<Credit>(&credit_json, "type", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Credit>(&credit_json, "type", json!("notAThing")),
expected
);
}
#[test]
fn test_radarr_task_task_name_deserialization_falls_back_to_default() {
let task_json = serde_json::to_string(&task()).unwrap();
let expected = RadarrTask {
task_name: RadarrTaskName::default(),
..task()
};
assert_eq!(
deserialize_with_field::<RadarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<RadarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> RadarrHistoryEventType {
let history_item_json = json!({
"id": 1,
+5
View File
@@ -27,6 +27,7 @@ pub struct Author {
pub id: i64,
pub author_name: HorizontallyScrollableText,
pub foreign_author_id: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: AuthorStatus,
pub overview: Option<String>,
pub author_type: Option<String>,
@@ -38,6 +39,7 @@ pub struct Author {
#[serde(deserialize_with = "super::from_i64")]
pub metadata_profile_id: i64,
pub monitored: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub monitor_new_items: NewItemMonitorType,
pub genres: Vec<String>,
pub tags: Vec<Number>,
@@ -152,6 +154,7 @@ pub enum MonitorType {
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
pub title: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
@@ -210,6 +213,7 @@ pub struct DownloadsResponse {
pub struct AddAuthorSearchResult {
pub foreign_author_id: String,
pub author_name: HorizontallyScrollableText,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: AuthorStatus,
#[serde(default)]
pub ended: bool,
@@ -423,6 +427,7 @@ pub struct ReadarrHistoryItem {
#[serde(rename_all = "camelCase")]
pub struct ReadarrTask {
pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: ReadarrTaskName,
#[serde(deserialize_with = "super::from_i64")]
pub interval: i64,
+111
View File
@@ -2,6 +2,7 @@
mod tests {
use chrono::Utc;
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::readarr_models::{
@@ -19,6 +20,9 @@ mod tests {
Serdeable,
readarr_models::{Author, AuthorStatistics, AuthorStatus, Ratings, ReadarrSerdeable},
};
use crate::network::readarr_network::readarr_network_test_utils::test_utils::{
ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, download_record,
};
#[test]
fn test_author_status_default() {
@@ -1140,6 +1144,113 @@ mod tests {
assert_none!(&search_result.ratings);
}
#[test]
fn test_author_status_deserialization_falls_back_to_default() {
let expected = Author {
status: AuthorStatus::default(),
..serde_json::from_str(AUTHOR_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_author_monitor_new_items_deserialization_falls_back_to_default() {
let expected = Author {
monitor_new_items: NewItemMonitorType::default(),
..serde_json::from_str(AUTHOR_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "monitorNewItems", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "monitorNewItems", json!("notAThing")),
expected
);
}
#[test]
fn test_add_author_search_result_status_deserialization_falls_back_to_default() {
let expected = AddAuthorSearchResult {
status: AuthorStatus::default(),
..serde_json::from_str(ADD_AUTHOR_SEARCH_RESULT_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<AddAuthorSearchResult>(
ADD_AUTHOR_SEARCH_RESULT_JSON,
"status",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<AddAuthorSearchResult>(
ADD_AUTHOR_SEARCH_RESULT_JSON,
"status",
json!("notAThing")
),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_readarr_task_task_name_deserialization_falls_back_to_default() {
let task = ReadarrTask {
name: "Backup".to_owned(),
task_name: ReadarrTaskName::Backup,
..ReadarrTask::default()
};
let task_json = serde_json::to_string(&task).unwrap();
let expected = ReadarrTask {
task_name: ReadarrTaskName::default(),
..task
};
assert_eq!(
deserialize_with_field::<ReadarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<ReadarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> ReadarrHistoryEventType {
let history_item_json = json!({
"id": 1,
@@ -31,8 +31,8 @@ use strum::EnumIter;
use {
super::modals::EditionDetailsModal,
crate::models::readarr_models::{
AuthorStatus, BookFile, DownloadStatus, Edition, MediaInfo, MonitorType, NewItemMonitorType,
Ratings, ReadarrHistoryEventType, ReadarrTaskName,
AuthorStatus, BookFile, BookStatistics, DownloadStatus, Edition, MediaInfo, MonitorType,
NewItemMonitorType, Ratings, ReadarrHistoryEventType, ReadarrTaskName,
},
crate::models::servarr_models::{IndexerField, Quality, QualityWrapper},
crate::models::stateful_table::SortOption,
@@ -292,6 +292,12 @@ impl ReadarrData<'_> {
any_edition_ok: true,
page_count: Some(288),
release_date: Some(date),
statistics: Some(BookStatistics {
book_file_count: 3,
total_book_count: 7,
size_on_disk: 2469606195,
percent_of_books: 42.86,
}),
..Book::default()
};
let readarr_history_item = ReadarrHistoryItem {
+3
View File
@@ -315,7 +315,9 @@ pub struct RootFolder {
#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SecurityConfig {
#[serde(deserialize_with = "super::from_json_or_default")]
pub authentication_method: AuthenticationMethod,
#[serde(default, deserialize_with = "super::from_json_or_default")]
#[serde(skip_serializing_if = "Option::is_none")]
pub authentication_required: Option<AuthenticationRequired>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -323,6 +325,7 @@ pub struct SecurityConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
pub api_key: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub certificate_validation: CertificateValidation,
}
+117 -1
View File
@@ -1,10 +1,12 @@
#[cfg(test)]
mod tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::servarr_models::{
AuthenticationMethod, AuthenticationRequired, CertificateValidation, Indexer, QualityProfile,
Update, UpdateChanges,
SecurityConfig, Update, UpdateChanges,
};
#[test]
@@ -90,4 +92,118 @@ mod tests {
}
);
}
#[test]
fn test_security_config_authentication_method_deserialization_falls_back_to_default() {
let security_config_json = serde_json::to_string(&security_config()).unwrap();
let expected = SecurityConfig {
authentication_method: AuthenticationMethod::default(),
..security_config()
};
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationMethod",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationMethod",
json!("notAThing")
),
expected
);
}
#[test]
fn test_security_config_authentication_required_deserialization_falls_back_to_none() {
let security_config_json = serde_json::to_string(&security_config()).unwrap();
let expected = SecurityConfig {
authentication_required: None,
..security_config()
};
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationRequired",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationRequired",
json!("notAThing")
),
expected
);
}
#[test]
fn test_security_config_deserializes_when_authentication_required_is_absent() {
let mut security_config_json = serde_json::to_value(security_config()).unwrap();
security_config_json
.as_object_mut()
.unwrap()
.remove("authenticationRequired");
let expected = SecurityConfig {
authentication_required: None,
..security_config()
};
let deserialized = serde_json::from_value::<SecurityConfig>(security_config_json);
assert_ok!(&deserialized);
assert_eq!(deserialized.unwrap(), expected);
}
#[test]
fn test_security_config_certificate_validation_deserialization_falls_back_to_default() {
let security_config_json = serde_json::to_string(&security_config()).unwrap();
let expected = SecurityConfig {
certificate_validation: CertificateValidation::default(),
..security_config()
};
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"certificateValidation",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"certificateValidation",
json!("notAThing")
),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn security_config() -> SecurityConfig {
SecurityConfig {
authentication_method: AuthenticationMethod::Forms,
authentication_required: Some(AuthenticationRequired::Enabled),
username: Some("admin".to_owned()),
password: Some("password".to_owned()),
api_key: "test-api-key".to_owned(),
certificate_validation: CertificateValidation::Disabled,
}
}
}
+4
View File
@@ -112,6 +112,7 @@ pub struct DeleteSeriesParams {
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
pub title: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
@@ -314,12 +315,14 @@ pub struct Series {
#[serde(deserialize_with = "super::from_i64")]
pub year: i64,
pub monitored: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub series_type: SeriesType,
pub path: String,
pub genres: Vec<String>,
pub tags: Vec<Number>,
pub ratings: Rating,
pub ended: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: SeriesStatus,
pub overview: Option<String>,
pub network: Option<String>,
@@ -556,6 +559,7 @@ pub struct SonarrReleaseDownloadBody {
#[serde(rename_all = "camelCase")]
pub struct SonarrTask {
pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: SonarrTaskName,
#[serde(deserialize_with = "super::from_i64")]
pub interval: i64,
+81
View File
@@ -1,6 +1,7 @@
#[cfg(test)]
mod tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::{
@@ -17,6 +18,9 @@ mod tests {
SonarrTaskName,
},
};
use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{
SERIES_JSON, download_record, task,
};
#[test]
fn test_episode_display() {
@@ -698,6 +702,83 @@ mod tests {
);
}
#[test]
fn test_series_series_type_deserialization_falls_back_to_default() {
let expected = Series {
series_type: SeriesType::default(),
..serde_json::from_str(SERIES_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "seriesType", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "seriesType", json!("notAThing")),
expected
);
}
#[test]
fn test_series_status_deserialization_falls_back_to_default() {
let expected = Series {
status: SeriesStatus::default(),
..serde_json::from_str(SERIES_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_sonarr_task_task_name_deserialization_falls_back_to_default() {
let task_json = serde_json::to_string(&task()).unwrap();
let expected = SonarrTask {
task_name: SonarrTaskName::default(),
..task()
};
assert_eq!(
deserialize_with_field::<SonarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<SonarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> SonarrHistoryEventType {
let history_item_json = json!({
"id": 1,
@@ -184,9 +184,9 @@ pub mod test_utils {
DownloadRecord {
title: "Test download title".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
id: 9,
album_id: Some(Number::from(1i64)),
artist_id: Some(Number::from(1i64)),
artist_id: Some(Number::from(3i64)),
size: 3543348019f64,
sizeleft: 1771674009f64,
output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")),
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::models::radarr_models::{
AddMovieBody, AddMovieSearchResult, Credit, CreditType, DeleteMovieParams, DownloadRecord,
EditMovieParams, Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease,
DownloadStatus, EditMovieParams, Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease,
RadarrReleaseDownloadBody,
};
use crate::models::servarr_data::Notification;
@@ -664,11 +664,11 @@ pub(in crate::network::radarr_network::library) fn get_movie_status(
.iter()
.find(|&download| download.movie_id == movie_id)
{
if download.status == "downloading" {
if download.status == DownloadStatus::Downloading {
return "Downloading".to_owned();
}
if download.status == "completed" {
if download.status == DownloadStatus::Completed {
return "Awaiting Import".to_owned();
}
}
@@ -1,8 +1,9 @@
#[cfg(test)]
mod tests {
use crate::models::radarr_models::{
AddMovieBody, AddMovieOptions, Credit, DeleteMovieParams, DownloadRecord, EditMovieParams,
MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, RadarrReleaseDownloadBody,
AddMovieBody, AddMovieOptions, Credit, DeleteMovieParams, DownloadRecord, DownloadStatus,
EditMovieParams, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor,
RadarrReleaseDownloadBody,
};
use crate::models::servarr_data::Notification;
use crate::models::servarr_data::radarr::modals::MovieDetailsModal;
@@ -1199,7 +1200,7 @@ mod tests {
false,
&[DownloadRecord {
movie_id: 1,
status: "downloading".to_owned(),
status: DownloadStatus::Downloading,
..DownloadRecord::default()
}],
1
@@ -1215,7 +1216,7 @@ mod tests {
false,
&[DownloadRecord {
movie_id: 1,
status: "completed".to_owned(),
status: DownloadStatus::Completed,
..DownloadRecord::default()
}],
1
@@ -2,9 +2,10 @@
pub mod test_utils {
use crate::models::radarr_models::{
AddMovieSearchResult, BlocklistItem, BlocklistItemMovie, Collection, CollectionMovie, Credit,
CreditType, DownloadRecord, DownloadsResponse, IndexerSettings, MediaInfo, MinimumAvailability,
Movie, MovieCollection, MovieFile, MovieHistoryItem, RadarrHistoryData, RadarrHistoryEventType,
RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating, RatingsList,
CreditType, DownloadRecord, DownloadStatus, DownloadsResponse, IndexerSettings, MediaInfo,
MinimumAvailability, Movie, MovieCollection, MovieFile, MovieHistoryItem, RadarrHistoryData,
RadarrHistoryEventType, RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating,
RatingsList,
};
use crate::models::servarr_models::{
Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
@@ -339,7 +340,7 @@ pub mod test_utils {
pub fn download_record() -> DownloadRecord {
DownloadRecord {
title: "Test Download Title".to_owned(),
status: "downloading".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
movie_id: 1,
size: 3543348019,
+33 -6
View File
@@ -1,6 +1,8 @@
use crate::app::App;
use crate::models::Route;
use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease, Track};
use crate::models::lidarr_models::{
DownloadRecord, DownloadStatus, LidarrHistoryItem, LidarrRelease, Track,
};
use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock};
use crate::ui::lidarr_ui::library::track_details_ui::TrackDetailsUi;
use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details;
@@ -150,6 +152,7 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.expect("Album details modal is unpopulated")
.tracks,
);
let downloads_vec = &app.data.lidarr_data.downloads.items;
let track_row_mapping = |track: &Track| {
let Track {
@@ -187,6 +190,7 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let duration_str = format!("{mins}:{secs:02}");
decorate_track_row_with_style(
downloads_vec,
track,
Row::new(vec![
Cell::from(track_number.clone()),
@@ -221,12 +225,35 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
}
}
fn decorate_track_row_with_style<'a>(track: &Track, row: Row<'a>) -> Row<'a> {
if track.has_file {
row.downloaded()
} else {
row.missing()
fn decorate_track_row_with_style<'a>(
downloads_vec: &[DownloadRecord],
track: &Track,
row: Row<'a>,
) -> Row<'a> {
if !track.has_file {
let default_album_id = Number::from(-1i64);
if let Some(download) = downloads_vec.iter().find(|&download| {
download
.album_id
.as_ref()
.unwrap_or(&default_album_id)
.as_i64()
.unwrap()
== track.album_id
}) {
if download.status == DownloadStatus::Downloading {
return row.downloading();
}
if download.status == DownloadStatus::Completed {
return row.awaiting_import();
}
}
return row.missing();
}
row.downloaded()
}
fn draw_album_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
@@ -2,14 +2,16 @@
mod tests {
use pretty_assertions::assert_eq;
use ratatui::widgets::{Cell, Row};
use serde_json::Number;
use strum::IntoEnumIterator;
use crate::app::App;
use crate::models::lidarr_models::Track;
use crate::models::lidarr_models::{DownloadRecord, DownloadStatus, Track};
use crate::models::servarr_data::lidarr::lidarr_data::{
ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock, TRACK_DETAILS_BLOCKS,
};
use crate::models::stateful_table::StatefulTable;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::download_record;
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::album_details_ui::{
AlbumDetailsUi, decorate_track_row_with_style,
@@ -39,7 +41,7 @@ mod tests {
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&track, row.clone());
let style = decorate_track_row_with_style(&[], &track, row.clone());
assert_eq!(style, row.downloaded());
}
@@ -52,11 +54,89 @@ mod tests {
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&track, row.clone());
let style = decorate_track_row_with_style(&[], &track, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_track_row_with_style_downloading_when_track_has_no_file_and_album_is_downloading()
{
let downloads_vec = vec![album_download(7, DownloadStatus::Downloading)];
let track = Track {
has_file: false,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.downloading());
}
#[test]
fn test_decorate_track_row_with_style_awaiting_import_when_track_has_no_file_and_album_download_is_completed()
{
let downloads_vec = vec![album_download(7, DownloadStatus::Completed)];
let track = Track {
has_file: false,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.awaiting_import());
}
#[test]
fn test_decorate_track_row_with_style_missing_when_album_download_is_neither_downloading_nor_completed()
{
let downloads_vec = vec![album_download(7, DownloadStatus::Queued)];
let track = Track {
has_file: false,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_track_row_with_style_missing_when_only_a_different_album_is_downloading() {
let downloads_vec = vec![album_download(8, DownloadStatus::Downloading)];
let track = Track {
has_file: false,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_track_row_with_style_downloaded_when_track_has_file_and_album_is_downloading() {
let downloads_vec = vec![album_download(7, DownloadStatus::Downloading)];
let track = Track {
has_file: true,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.downloaded());
}
mod snapshot_tests {
use crate::ui::ui_test_utils::test_utils::TerminalSize;
use rstest::rstest;
@@ -177,4 +257,13 @@ mod tests {
insta::assert_snapshot!(output);
}
}
fn album_download(album_id: i64, status: DownloadStatus) -> DownloadRecord {
DownloadRecord {
status,
album_id: Some(Number::from(album_id)),
artist_id: Some(Number::from(3i64)),
..download_record()
}
}
}
@@ -279,13 +279,16 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.release_date
.map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string());
let track_count = album.statistics.as_ref().map_or_else(
|| "0/0".to_owned(),
|| "N/A".to_owned(),
|s| format!("{}/{}", s.track_file_count, s.total_track_count),
);
let size = album
.statistics
.as_ref()
.map_or(0f64, |s| convert_to_gb(s.size_on_disk));
let size = album.statistics.as_ref().map_or_else(
|| "N/A".to_owned(),
|s| {
let size = convert_to_gb(s.size_on_disk);
format!("{size:.2} GB")
},
);
let duration_mins = album.duration / 60000;
decorate_album_row_with_style(
@@ -297,7 +300,7 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Cell::from(track_count),
Cell::from(format!("{duration_mins} min")),
Cell::from(release_date),
Cell::from(format!("{size:.2} GB")),
Cell::from(size),
]),
)
};
@@ -249,6 +249,19 @@ mod tests {
);
}
#[test]
fn test_artist_details_ui_renders_album_without_statistics() {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.albums.items[0].statistics = None;
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
ArtistDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_artist_details_ui_renders_delete_album_prompt_over_artist_details() {
let mut app = App::test_default_fully_populated();
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│
│seven years to finish. │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 12/15 │
│╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Albums │ History │ Manual Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album N/A 0 min 2023-01-01 N/A ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -1,11 +1,14 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use strum::IntoEnumIterator;
use crate::app::App;
use crate::models::lidarr_models::Track;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, TRACK_DETAILS_BLOCKS};
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::track_details_ui::TrackDetailsUi;
use crate::ui::lidarr_ui::library::track_details_ui::{TrackDetailsUi, style_from_status};
use crate::ui::styles::{downloaded_style, missing_style};
use crate::ui::ui_test_utils::test_utils::render_to_string_with_app;
#[test]
@@ -19,6 +22,30 @@ mod tests {
});
}
#[test]
fn test_style_from_status_downloaded_when_track_has_file() {
let track = Track {
has_file: true,
..Track::default()
};
let style = style_from_status(&track);
assert_eq!(style, downloaded_style());
}
#[test]
fn test_style_from_status_missing_when_track_has_no_file() {
let track = Track {
has_file: false,
..Track::default()
};
let style = style_from_status(&track);
assert_eq!(style, missing_style());
}
mod snapshot_tests {
use crate::ui::ui_test_utils::test_utils::TerminalSize;
use rstest::rstest;
+3 -3
View File
@@ -1,7 +1,7 @@
use crate::app::App;
use crate::logos::RADARR_LOGO;
use crate::models::Route;
use crate::models::radarr_models::{DownloadRecord, Movie};
use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie};
use crate::models::servarr_data::radarr::radarr_data::RadarrData;
use crate::models::servarr_models::{DiskSpace, RootFolder};
use crate::ui::DrawUi;
@@ -230,11 +230,11 @@ fn decorate_with_row_style<'a>(
.iter()
.find(|&download| download.movie_id == movie.id)
{
if download.status == "downloading" {
if download.status == DownloadStatus::Downloading {
return row.downloading();
}
if download.status == "completed" {
if download.status == DownloadStatus::Completed {
return row.awaiting_import();
}
}
+17 -5
View File
@@ -1,6 +1,6 @@
#[cfg(test)]
mod tests {
use crate::models::radarr_models::{DownloadRecord, Movie};
use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie};
use pretty_assertions::assert_eq;
use ratatui::widgets::{Cell, Row};
use rstest::rstest;
@@ -50,8 +50,20 @@ mod tests {
}
#[rstest]
#[case(false, Some("downloading"), false, "", RowStyle::Downloading)]
#[case(false, Some("completed"), false, "", RowStyle::AwaitingImport)]
#[case(
false,
Some(DownloadStatus::Downloading),
false,
"",
RowStyle::Downloading
)]
#[case(
false,
Some(DownloadStatus::Completed),
false,
"",
RowStyle::AwaitingImport
)]
#[case(false, None, false, "", RowStyle::UnmonitoredMissing)]
#[case(false, None, true, "", RowStyle::Unreleased)]
#[case(false, None, true, "released", RowStyle::Missing)]
@@ -59,7 +71,7 @@ mod tests {
#[case(true, None, true, "", RowStyle::Downloaded)]
fn test_decorate_with_row_style(
#[case] has_file: bool,
#[case] download_status: Option<&str>,
#[case] download_status: Option<DownloadStatus>,
#[case] is_monitored: bool,
#[case] movie_status: String,
#[case] expected_style: RowStyle,
@@ -67,7 +79,7 @@ mod tests {
let downloads_vec = if let Some(download_status) = download_status {
vec![DownloadRecord {
movie_id: 1,
status: download_status.to_owned(),
status: download_status,
..DownloadRecord::default()
}]
} else {
@@ -275,13 +275,16 @@ fn draw_books_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.release_date
.map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string());
let book_file_count = book.statistics.as_ref().map_or_else(
|| "0/0".to_owned(),
|| "N/A".to_owned(),
|s| format!("{}/{}", s.book_file_count, s.total_book_count),
);
let size = book
.statistics
.as_ref()
.map_or(0f64, |s| convert_to_gb(s.size_on_disk));
let size = book.statistics.as_ref().map_or_else(
|| "N/A".to_owned(),
|s| {
let size = convert_to_gb(s.size_on_disk);
format!("{size:.2} GB")
},
);
decorate_book_row_with_style(
book,
@@ -290,7 +293,7 @@ fn draw_books_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Cell::from(book.title.to_string()),
Cell::from(book_file_count),
Cell::from(release_date),
Cell::from(format!("{size:.2} GB")),
Cell::from(size),
]),
)
};
@@ -271,6 +271,19 @@ mod tests {
);
}
#[test]
fn test_author_details_ui_renders_book_without_statistics() {
let mut app = App::test_default_fully_populated();
app.data.readarr_data.books.items[0].statistics = None;
app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
AuthorDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_author_details_ui_renders_update_and_scan_prompt_over_author_details() {
let mut app = App::test_default_fully_populated();
@@ -22,7 +22,7 @@ expression: output
││ Books │ History │ Author Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Files Release Date Size ││
││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││
││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││
││ ││
││ ││
││ ││
@@ -22,7 +22,7 @@ expression: output
││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││
││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││
││ Monitored Title │ │Date Size ││
││=> 🏷 Test Book │ │20 0.00 GB ││
││=> 🏷 Test Book │ │20 2.30 GB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -22,7 +22,7 @@ expression: output
││ Books │ History │ Author Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Files Release Date Size ││
││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││
││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││
││ ││
││ ││
││ ││
@@ -22,7 +22,7 @@ expression: output
││ Books │ History │ Author Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Files Release Date Size ││
││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││
││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││
││ ││
││ ││
││ ││
@@ -22,7 +22,7 @@ expression: output
││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││
││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││
││ Monitored Title │ │Date Size ││
││=> 🏷 Test Book │ │20 0.00 GB ││
││=> 🏷 Test Book │ │20 2.30 GB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -22,7 +22,7 @@ expression: output
││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││
││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││
││ Monitored Title │ │Date Size ││
││=> 🏷 Test Book │ │20 0.00 GB ││
││=> 🏷 Test Book │ │20 2.30 GB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -0,0 +1,52 @@
---
source: src/ui/readarr_ui/library/author_details_ui_tests.rs
expression: output
---
╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Author: Test Author │
│Status: Continuing │
│Genres: science fiction │
│Rating: N/A │
│Path: /nfs/books/Test Author │
│Quality Profile: Standard │
│Metadata Profile: Comprehensive │
│Monitored: Yes │
│Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │
│seven years to finish. │
│ │
│ │
│╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Books │ History │ Author Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Files Release Date Size ││
││=> 🏷 Test Book N/A 2023-05-20 N/A ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -22,7 +22,7 @@ expression: output
││ Books │ History │ Author Search │ Do you really want to delete the book: │ ││
││─────────────────────────────────────────│ Test Book? │───────────────────────────────────────────││
││ Monitored Title │ │Date Size ││
││=> 🏷 Test Book │ │20 0.00 GB ││
││=> 🏷 Test Book │ │20 2.30 GB ││
││ │ ╭───╮ │ ││
││ │ Delete Book Files: │ ✔ │ │ ││
││ │ ╰───╯ │ ││
@@ -22,7 +22,7 @@ expression: output
││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││
││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││
││ Monitored Title │ │Date Size ││
││=> 🏷 Test Book │ │20 0.00 GB ││
││=> 🏷 Test Book │ │20 2.30 GB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -22,7 +22,7 @@ expression: output
││ Books │ History │ Author Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Files Release Date Size ││
││=> 🏷 Test Book 0/0 2023-05-20 0.00 GB ││
││=> 🏷 Test Book 3/7 2023-05-20 2.30 GB ││
││ ││
││ ││
││ ││
@@ -1,13 +1,20 @@
#[cfg(test)]
mod tests {
use chrono::{Duration, Utc};
use pretty_assertions::assert_eq;
use strum::IntoEnumIterator;
use crate::app::App;
use crate::models::servarr_data::sonarr::sonarr_data::{
ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS,
};
use crate::models::sonarr_models::{DownloadRecord, DownloadStatus, Episode};
use crate::ui::DrawUi;
use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi;
use crate::ui::sonarr_ui::library::episode_details_ui::{EpisodeDetailsUi, style_from_status};
use crate::ui::styles::{
awaiting_import_style, downloaded_style, downloading_style, missing_style,
unmonitored_missing_style, unmonitored_style, unreleased_style,
};
use crate::ui::ui_test_utils::test_utils::render_to_string_with_app;
#[test]
@@ -21,6 +28,157 @@ mod tests {
});
}
#[test]
fn test_style_from_status_downloading_when_episode_has_no_file_and_download_is_downloading() {
let download = DownloadRecord {
status: DownloadStatus::Downloading,
..DownloadRecord::default()
};
let episode = Episode {
has_file: false,
monitored: true,
..Episode::default()
};
let style = style_from_status(Some(&download), &episode);
assert_eq!(style, downloading_style());
}
#[test]
fn test_style_from_status_awaiting_import_when_episode_has_no_file_and_download_is_completed() {
let download = DownloadRecord {
status: DownloadStatus::Completed,
..DownloadRecord::default()
};
let episode = Episode {
has_file: false,
monitored: true,
..Episode::default()
};
let style = style_from_status(Some(&download), &episode);
assert_eq!(style, awaiting_import_style());
}
#[test]
fn test_style_from_status_ignores_download_that_is_neither_downloading_nor_completed() {
let download = DownloadRecord {
status: DownloadStatus::Queued,
..DownloadRecord::default()
};
let episode = Episode {
has_file: false,
monitored: true,
air_date_utc: None,
..Episode::default()
};
let style = style_from_status(Some(&download), &episode);
assert_eq!(style, missing_style());
}
#[test]
fn test_style_from_status_unmonitored_missing_when_episode_has_no_file_and_is_unmonitored() {
let episode = Episode {
has_file: false,
monitored: false,
air_date_utc: Some(Utc::now() + Duration::days(1)),
..Episode::default()
};
let style = style_from_status(None, &episode);
assert_eq!(style, unmonitored_missing_style());
}
#[test]
fn test_style_from_status_unreleased_when_episode_has_no_file_and_air_date_is_future() {
let episode = Episode {
has_file: false,
monitored: true,
air_date_utc: Some(Utc::now() + Duration::days(1)),
..Episode::default()
};
let style = style_from_status(None, &episode);
assert_eq!(style, unreleased_style());
}
#[test]
fn test_style_from_status_missing_when_episode_has_no_file_and_air_date_has_passed() {
let episode = Episode {
has_file: false,
monitored: true,
air_date_utc: Some(Utc::now() - Duration::days(1)),
..Episode::default()
};
let style = style_from_status(None, &episode);
assert_eq!(style, missing_style());
}
#[test]
fn test_style_from_status_missing_when_episode_has_no_file_and_no_air_date() {
let episode = Episode {
has_file: false,
monitored: true,
air_date_utc: None,
..Episode::default()
};
let style = style_from_status(None, &episode);
assert_eq!(style, missing_style());
}
#[test]
fn test_style_from_status_unmonitored_when_episode_has_file_and_is_unmonitored() {
let episode = Episode {
has_file: true,
monitored: false,
..Episode::default()
};
let style = style_from_status(None, &episode);
assert_eq!(style, unmonitored_style());
}
#[test]
fn test_style_from_status_downloaded_when_episode_has_file_and_is_monitored() {
let episode = Episode {
has_file: true,
monitored: true,
..Episode::default()
};
let style = style_from_status(None, &episode);
assert_eq!(style, downloaded_style());
}
#[test]
fn test_style_from_status_ignores_download_when_episode_has_file() {
let download = DownloadRecord {
status: DownloadStatus::Downloading,
..DownloadRecord::default()
};
let episode = Episode {
has_file: true,
monitored: true,
..Episode::default()
};
let style = style_from_status(Some(&download), &episode);
assert_eq!(style, downloaded_style());
}
mod snapshot_tests {
use crate::ui::ui_test_utils::test_utils::TerminalSize;
use rstest::rstest;
@@ -1,14 +1,23 @@
#[cfg(test)]
mod tests {
use chrono::{Duration, Utc};
use pretty_assertions::assert_eq;
use ratatui::widgets::{Cell, Row};
use serde_json::Number;
use strum::IntoEnumIterator;
use crate::app::App;
use crate::models::servarr_data::sonarr::sonarr_data::{
ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS,
};
use crate::models::sonarr_models::{DownloadRecord, DownloadStatus, Episode};
use crate::models::stateful_table::StatefulTable;
use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::download_record;
use crate::ui::DrawUi;
use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi;
use crate::ui::sonarr_ui::library::season_details_ui::{
SeasonDetailsUi, decorate_with_row_style,
};
use crate::ui::styles::ManagarrStyle;
use crate::ui::ui_test_utils::test_utils::render_to_string_with_app;
#[test]
@@ -25,6 +34,235 @@ mod tests {
});
}
#[test]
fn test_decorate_with_row_style_downloaded_when_episode_has_file() {
let episode = Episode {
id: 5,
has_file: true,
monitored: true,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&[], &episode, row.clone());
assert_eq!(style, row.downloaded());
}
#[test]
fn test_decorate_with_row_style_unmonitored_when_episode_has_file_and_is_unmonitored() {
let episode = Episode {
id: 5,
has_file: true,
monitored: false,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&[], &episode, row.clone());
assert_eq!(style, row.unmonitored());
}
#[test]
fn test_decorate_with_row_style_missing_when_episode_has_no_file() {
let episode = Episode {
id: 5,
has_file: false,
monitored: true,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&[], &episode, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_with_row_style_unmonitored_missing_when_episode_has_no_file_and_is_unmonitored()
{
let episode = Episode {
id: 5,
has_file: false,
monitored: false,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&[], &episode, row.clone());
assert_eq!(style, row.unmonitored_missing());
}
#[test]
fn test_decorate_with_row_style_unreleased_when_episode_has_no_file_and_air_date_is_in_the_future()
{
let episode = Episode {
id: 5,
has_file: false,
monitored: true,
air_date_utc: Some(Utc::now() + Duration::days(1)),
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&[], &episode, row.clone());
assert_eq!(style, row.unreleased());
}
#[test]
fn test_decorate_with_row_style_missing_when_episode_has_no_file_and_air_date_is_in_the_past() {
let episode = Episode {
id: 5,
has_file: false,
monitored: true,
air_date_utc: Some(Utc::now() - Duration::days(1)),
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&[], &episode, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_with_row_style_unmonitored_missing_when_unmonitored_episode_has_no_file_and_air_date_is_in_the_future()
{
let episode = Episode {
id: 5,
has_file: false,
monitored: false,
air_date_utc: Some(Utc::now() + Duration::days(1)),
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&[], &episode, row.clone());
assert_eq!(style, row.unmonitored_missing());
}
#[test]
fn test_decorate_with_row_style_downloading_when_episode_has_no_file_and_is_downloading() {
let downloads_vec = vec![episode_download(5, DownloadStatus::Downloading)];
let episode = Episode {
id: 5,
has_file: false,
monitored: true,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&downloads_vec, &episode, row.clone());
assert_eq!(style, row.downloading());
}
#[test]
fn test_decorate_with_row_style_awaiting_import_when_episode_has_no_file_and_download_is_completed()
{
let downloads_vec = vec![episode_download(5, DownloadStatus::Completed)];
let episode = Episode {
id: 5,
has_file: false,
monitored: true,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&downloads_vec, &episode, row.clone());
assert_eq!(style, row.awaiting_import());
}
#[test]
fn test_decorate_with_row_style_missing_when_episode_download_is_neither_downloading_nor_completed()
{
let downloads_vec = vec![episode_download(5, DownloadStatus::Queued)];
let episode = Episode {
id: 5,
has_file: false,
monitored: true,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&downloads_vec, &episode, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_with_row_style_missing_when_only_a_different_episode_is_downloading() {
let downloads_vec = vec![episode_download(6, DownloadStatus::Downloading)];
let episode = Episode {
id: 5,
has_file: false,
monitored: true,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&downloads_vec, &episode, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_with_row_style_missing_when_download_has_no_episode_id() {
let downloads_vec = vec![DownloadRecord {
episode_id: None,
..episode_download(5, DownloadStatus::Downloading)
}];
let episode = Episode {
id: 5,
has_file: false,
monitored: true,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&downloads_vec, &episode, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_with_row_style_downloading_when_unmonitored_episode_has_no_file_and_is_downloading()
{
let downloads_vec = vec![episode_download(5, DownloadStatus::Downloading)];
let episode = Episode {
id: 5,
has_file: false,
monitored: false,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&downloads_vec, &episode, row.clone());
assert_eq!(style, row.downloading());
}
#[test]
fn test_decorate_with_row_style_downloaded_when_episode_has_file_and_is_downloading() {
let downloads_vec = vec![episode_download(5, DownloadStatus::Downloading)];
let episode = Episode {
id: 5,
has_file: true,
monitored: true,
..Episode::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_with_row_style(&downloads_vec, &episode, row.clone());
assert_eq!(style, row.downloaded());
}
mod snapshot_tests {
use crate::ui::ui_test_utils::test_utils::TerminalSize;
use rstest::rstest;
@@ -145,4 +383,13 @@ mod tests {
insta::assert_snapshot!(output);
}
}
fn episode_download(episode_id: i64, status: DownloadStatus) -> DownloadRecord {
DownloadRecord {
status,
id: 9,
episode_id: Some(Number::from(episode_id)),
..download_record()
}
}
}
+15 -18
View File
@@ -10,7 +10,7 @@ use ratatui::widgets::{Cell, Paragraph, Row, Wrap};
use crate::app::App;
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_DETAILS_BLOCKS};
use crate::models::sonarr_models::{Season, SeasonStatistics, SonarrHistoryItem};
use crate::models::sonarr_models::{Season, SonarrHistoryItem};
use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi;
use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi;
use crate::ui::sonarr_ui::library::series_overview_ui::SeriesOverviewUi;
@@ -229,29 +229,26 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() {
let content = Some(&mut app.data.sonarr_data.seasons);
let season_row_mapping = |season: &Season| {
let Season {
title, statistics, ..
} = season;
let SeasonStatistics {
episode_file_count,
episode_count,
size_on_disk,
..
} = if let Some(stats) = statistics {
stats
} else {
&SeasonStatistics::default()
};
let season_monitored = if season.monitored { "🏷" } else { "" };
let size = convert_to_gb(*size_on_disk);
let episode_count = season.statistics.as_ref().map_or_else(
|| "N/A".to_owned(),
|s| format!("{}/{}", s.episode_file_count, s.episode_count),
);
let size = season.statistics.as_ref().map_or_else(
|| "N/A".to_owned(),
|s| {
let size = convert_to_gb(s.size_on_disk);
format!("{size:.2} GB")
},
);
decorate_season_row_with_style(
season,
Row::new(vec![
Cell::from(season_monitored.to_owned()),
Cell::from(title.clone().unwrap_or_default()),
Cell::from(format!("{episode_file_count}/{episode_count}")),
Cell::from(format!("{size:.2} GB")),
Cell::from(season.title.clone().unwrap_or_default()),
Cell::from(episode_count),
Cell::from(size),
]),
)
};
@@ -239,6 +239,19 @@ mod tests {
);
}
#[test]
fn test_series_details_ui_renders_season_without_statistics() {
let mut app = App::test_default_fully_populated();
app.data.sonarr_data.seasons.items[0].statistics = None;
app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
SeriesDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_series_details_ui_renders_season_details_over_series_details() {
let mut app = App::test_default_fully_populated();
@@ -0,0 +1,52 @@
---
source: src/ui/sonarr_ui/library/series_details_ui_tests.rs
expression: output
---
╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Title: Test │
│Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │
│Network: HBO │
│Status: Continuing │
│Genres: cool, family, fun │
│Rating: 84% │
│Year: 2022 │
│Runtime: 63 minutes │
│Path: /nfs/tv/Test │
│Quality Profile: Bluray-1080p │
│Language Profile: English │
│Monitored: Yes │
│Size on Disk: 59.51 GB │
│ │
│ │
│ │
│╭ Series Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Seasons │ History ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Season Episode Count Size on Disk ││
││=> 🏷 Season title N/A N/A ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯