From 460efb2497ae744b4e462364ddffd8ccaf452537 Mon Sep 17 00:00:00 2001 From: Dark-Alex-17 Date: Tue, 8 Aug 2023 10:50:06 -0600 Subject: [PATCH] Completed initial implementation of logs, events, and tasks --- src/app/radarr.rs | 9 +- src/app/radarr_tests.rs | 7 +- src/models/radarr_models.rs | 13 ++ src/network/network_tests.rs | 2 +- src/network/radarr_network.rs | 29 +++- src/network/radarr_network_tests.rs | 42 ++++++ src/ui/mod.rs | 18 ++- src/ui/radarr_ui/add_movie_ui.rs | 5 +- src/ui/radarr_ui/collection_details_ui.rs | 5 +- src/ui/radarr_ui/collections_ui.rs | 1 + src/ui/radarr_ui/downloads_ui.rs | 1 + src/ui/radarr_ui/library_ui.rs | 1 + src/ui/radarr_ui/movie_details_ui.rs | 6 +- src/ui/radarr_ui/root_folders_ui.rs | 1 + src/ui/radarr_ui/system_ui.rs | 154 ++++++++++++++++++---- src/ui/radarr_ui/system_ui_tests.rs | 52 ++++++++ 16 files changed, 304 insertions(+), 42 deletions(-) create mode 100644 src/ui/radarr_ui/system_ui_tests.rs diff --git a/src/app/radarr.rs b/src/app/radarr.rs index 3b98040..9115b58 100644 --- a/src/app/radarr.rs +++ b/src/app/radarr.rs @@ -4,7 +4,7 @@ use strum::IntoEnumIterator; use crate::app::{App, Route}; use crate::models::radarr_models::{ - AddMovieSearchResult, Collection, CollectionMovie, Credit, DiskSpace, DownloadRecord, Log, + AddMovieSearchResult, Collection, CollectionMovie, Credit, DiskSpace, DownloadRecord, Event, Log, MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release, ReleaseField, RootFolder, Task, }; use crate::models::{ @@ -51,6 +51,7 @@ pub struct RadarrData<'a> { pub collection_movies: StatefulTable, pub logs: StatefulList, pub tasks: StatefulTable, + pub events: StatefulTable, pub prompt_confirm_action: Option, pub main_tabs: TabState, pub movie_info_tabs: TabState, @@ -269,6 +270,7 @@ impl<'a> Default for RadarrData<'a> { collection_movies: StatefulTable::default(), logs: StatefulList::default(), tasks: StatefulTable::default(), + events: StatefulTable::default(), prompt_confirm_action: None, search: HorizontallyScrollableText::default(), filter: HorizontallyScrollableText::default(), @@ -311,7 +313,7 @@ impl<'a> Default for RadarrData<'a> { title: "System", route: ActiveRadarrBlock::System.into(), help: "", - contextual_help: Some(" select block | go back to block selection") + contextual_help: Some(" open tasks | open queue | open logs") } ]), movie_info_tabs: TabState::new(vec![ @@ -554,6 +556,9 @@ impl<'a> App<'a> { self .dispatch_network_event(RadarrEvent::GetTasks.into()) .await; + self + .dispatch_network_event(RadarrEvent::GetEvents.into()) + .await; self .dispatch_network_event(RadarrEvent::GetLogs.into()) .await; diff --git a/src/app/radarr_tests.rs b/src/app/radarr_tests.rs index 8f06bf0..207e75f 100644 --- a/src/app/radarr_tests.rs +++ b/src/app/radarr_tests.rs @@ -280,6 +280,7 @@ mod tests { assert!(radarr_data.collection_movies.items.is_empty()); assert!(radarr_data.logs.items.is_empty()); assert!(radarr_data.tasks.items.is_empty()); + assert!(radarr_data.events.items.is_empty()); assert!(radarr_data.prompt_confirm_action.is_none()); assert!(radarr_data.search.text.is_empty()); assert!(radarr_data.filter.text.is_empty()); @@ -344,7 +345,7 @@ mod tests { assert!(radarr_data.main_tabs.tabs[4].help.is_empty()); assert_eq!( radarr_data.main_tabs.tabs[4].contextual_help, - Some(" select menu item | go back to menu selection") + Some(" open tasks | open queue | open logs") ); assert_eq!(radarr_data.movie_info_tabs.tabs.len(), 6); @@ -685,6 +686,10 @@ mod tests { sync_network_rx.recv().await.unwrap(), RadarrEvent::GetTasks.into() ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetEvents.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetLogs.into() diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index a20c550..a8b765a 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -437,3 +437,16 @@ pub struct Task { pub last_duration: String, pub next_execution: DateTime, } + +#[derive(Default, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Event { + pub trigger: String, + pub name: String, + pub command_name: String, + pub status: String, + pub queued: DateTime, + pub started: Option>, + pub ended: Option>, + pub duration: String, +} diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 4925523..497f262 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -33,7 +33,7 @@ mod tests { app.is_loading = true; let radarr_config = RadarrConfig { host, - api_token: String::default(), + api_token: String::new(), port, }; app.config.radarr = radarr_config; diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index cd78be0..5d814a6 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -10,9 +10,9 @@ use crate::app::radarr::ActiveRadarrBlock; use crate::app::RadarrConfig; use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, AddRootFolderBody, Collection, CollectionMovie, - CommandBody, Credit, CreditType, DiskSpace, DownloadRecord, DownloadsResponse, LogResponse, - Movie, MovieCommandBody, MovieHistoryItem, QualityProfile, Release, ReleaseDownloadBody, - RootFolder, SystemStatus, Tag, Task, + CommandBody, Credit, CreditType, DiskSpace, DownloadRecord, DownloadsResponse, Event, + LogResponse, Movie, MovieCommandBody, MovieHistoryItem, QualityProfile, Release, + ReleaseDownloadBody, RootFolder, SystemStatus, Tag, Task, }; use crate::models::{Route, Scrollable, ScrollableText}; use crate::network::{Network, NetworkEvent, RequestMethod, RequestProps}; @@ -34,6 +34,7 @@ pub enum RadarrEvent { EditCollection, GetCollections, GetDownloads, + GetEvents, GetLogs, GetMovieCredits, GetMovieDetails, @@ -78,7 +79,8 @@ impl RadarrEvent { RadarrEvent::GetStatus => "/system/status", RadarrEvent::GetTags => "/tag", RadarrEvent::GetTasks => "/system/task", - RadarrEvent::TriggerAutomaticSearch + RadarrEvent::GetEvents + | RadarrEvent::TriggerAutomaticSearch | RadarrEvent::UpdateAndScan | RadarrEvent::UpdateAllMovies | RadarrEvent::UpdateDownloads @@ -107,6 +109,7 @@ impl<'a, 'b> Network<'a, 'b> { RadarrEvent::EditCollection => self.edit_collection().await, RadarrEvent::GetCollections => self.get_collections().await, RadarrEvent::GetDownloads => self.get_downloads().await, + RadarrEvent::GetEvents => self.get_events().await, RadarrEvent::GetLogs => self.get_logs().await, RadarrEvent::GetMovieCredits => self.get_credits().await, RadarrEvent::GetMovieDetails => self.get_movie_details().await, @@ -601,6 +604,24 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_events(&self) { + info!("Fetching Radarr events"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetEvents.resource(), + RequestMethod::Get, + None::<()>, + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |events_vec, mut app| { + app.data.radarr_data.events.set_items(events_vec); + }) + .await; + } + async fn get_quality_profiles(&self) { info!("Fetching Radarr quality profiles"); diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 73dec4e..fc67a13 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -826,6 +826,48 @@ mod test { ); } + #[tokio::test] + async fn test_handle_get_events_event() { + let events_json = json!([{ + "name": "RefreshMonitoredDownloads", + "commandName": "Refresh Monitored Downloads", + "status": "completed", + "queued": "2023-05-20T21:29:16Z", + "started": "2023-05-20T21:29:16Z", + "ended": "2023-05-20T21:29:16Z", + "duration": "00:00:00.5111547", + "trigger": "scheduled", + }]); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_event = Event { + name: "RefreshMonitoredDownloads".to_owned(), + command_name: "Refresh Monitored Downloads".to_owned(), + status: "completed".to_owned(), + queued: timestamp, + started: Some(timestamp), + ended: Some(timestamp), + duration: "00:00:00.5111547".to_owned(), + trigger: "scheduled".to_owned(), + }; + + let (async_server, app_arc, _server) = mock_radarr_api( + RequestMethod::Get, + None, + Some(events_json), + RadarrEvent::GetEvents.resource(), + ) + .await; + let network = Network::new(reqwest::Client::new(), &app_arc); + + network.handle_radarr_event(RadarrEvent::GetEvents).await; + + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.events.items, + vec![expected_event] + ); + } + #[tokio::test] async fn test_handle_get_logs_event() { let resource = format!( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 944b52d..4365df3 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -303,6 +303,7 @@ fn draw_table<'a, B, T, F>( table_props: TableProps<'a, T>, row_mapper: F, is_loading: bool, + highlight: bool, ) where B: Backend, F: Fn(&T) -> Row<'a>, @@ -316,7 +317,7 @@ fn draw_table<'a, B, T, F>( let content_area = if let Some(help_string) = help { let chunks = vertical_chunks( - vec![Constraint::Min(0), Constraint::Length(3)], + vec![Constraint::Min(0), Constraint::Length(2)], content_area, ); let mut help_text = Text::from(format!(" {}", help_string)); @@ -339,12 +340,15 @@ fn draw_table<'a, B, T, F>( .style(style_default_bold()) .bottom_margin(0); - let table = Table::new(rows) - .header(headers) - .block(block) - .highlight_style(style_highlight()) - .highlight_symbol(HIGHLIGHT_SYMBOL) - .widths(&constraints); + let mut table = Table::new(rows).header(headers).block(block); + + if highlight { + table = table + .highlight_style(style_highlight()) + .highlight_symbol(HIGHLIGHT_SYMBOL); + } + + table = table.widths(&constraints); f.render_stateful_widget(table, content_area, &mut content.state); } else { diff --git a/src/ui/radarr_ui/add_movie_ui.rs b/src/ui/radarr_ui/add_movie_ui.rs index 82e48a4..4460d88 100644 --- a/src/ui/radarr_ui/add_movie_ui.rs +++ b/src/ui/radarr_ui/add_movie_ui.rs @@ -202,12 +202,12 @@ fn draw_add_movie_search(f: &mut Frame<'_, B>, app: &mut App<'_>, ar .as_u64() .unwrap(); let imdb_rating = if imdb_rating == 0.0 { - String::default() + String::new() } else { format!("{:.1}", imdb_rating) }; let rotten_tomatoes_rating = if rotten_tomatoes_rating == 0 { - String::default() + String::new() } else { format!("{}%", rotten_tomatoes_rating) }; @@ -242,6 +242,7 @@ fn draw_add_movie_search(f: &mut Frame<'_, B>, app: &mut App<'_>, ar .style(style_primary()) }, app.is_loading, + true, ); } _ => (), diff --git a/src/ui/radarr_ui/collection_details_ui.rs b/src/ui/radarr_ui/collection_details_ui.rs index ca3c14f..b9ec533 100644 --- a/src/ui/radarr_ui/collection_details_ui.rs +++ b/src/ui/radarr_ui/collection_details_ui.rs @@ -200,12 +200,12 @@ pub(super) fn draw_collection_details( .as_u64() .unwrap(); let imdb_rating = if imdb_rating == 0.0 { - String::default() + String::new() } else { format!("{:.1}", imdb_rating) }; let rotten_tomatoes_rating = if rotten_tomatoes_rating == 0 { - String::default() + String::new() } else { format!("{}%", rotten_tomatoes_rating) }; @@ -222,6 +222,7 @@ pub(super) fn draw_collection_details( .style(style_primary()) }, app.is_loading, + true, ); } diff --git a/src/ui/radarr_ui/collections_ui.rs b/src/ui/radarr_ui/collections_ui.rs index f0f0af9..a427c59 100644 --- a/src/ui/radarr_ui/collections_ui.rs +++ b/src/ui/radarr_ui/collections_ui.rs @@ -130,6 +130,7 @@ pub(super) fn draw_collections(f: &mut Frame<'_, B>, app: &mut App<' .style(style_primary()) }, app.is_loading, + true, ); } diff --git a/src/ui/radarr_ui/downloads_ui.rs b/src/ui/radarr_ui/downloads_ui.rs index 0115e89..2b6266c 100644 --- a/src/ui/radarr_ui/downloads_ui.rs +++ b/src/ui/radarr_ui/downloads_ui.rs @@ -111,6 +111,7 @@ fn draw_downloads(f: &mut Frame<'_, B>, app: &mut App<'_>, area: Rec .style(style_primary()) }, app.is_loading, + true, ); } diff --git a/src/ui/radarr_ui/library_ui.rs b/src/ui/radarr_ui/library_ui.rs index 930a4ea..c2f71b7 100644 --- a/src/ui/radarr_ui/library_ui.rs +++ b/src/ui/radarr_ui/library_ui.rs @@ -141,6 +141,7 @@ pub(super) fn draw_library(f: &mut Frame<'_, B>, app: &mut App<'_>, .style(determine_row_style(downloads_vec, movie)) }, app.is_loading, + true, ); } diff --git a/src/ui/radarr_ui/movie_details_ui.rs b/src/ui/radarr_ui/movie_details_ui.rs index f0d34e4..b152c45 100644 --- a/src/ui/radarr_ui/movie_details_ui.rs +++ b/src/ui/radarr_ui/movie_details_ui.rs @@ -304,6 +304,7 @@ fn draw_movie_history(f: &mut Frame<'_, B>, app: &mut App<'_>, conte .style(style_success()) }, app.is_loading, + true, ); } } @@ -337,6 +338,7 @@ fn draw_movie_cast(f: &mut Frame<'_, B>, app: &mut App<'_>, content_ .style(style_success()) }, app.is_loading, + true, ); } @@ -371,6 +373,7 @@ fn draw_movie_crew(f: &mut Frame<'_, B>, app: &mut App<'_>, content_ .style(style_success()) }, app.is_loading, + true, ); } @@ -475,7 +478,7 @@ fn draw_movie_releases(f: &mut Frame<'_, B>, app: &mut App<'_>, cont let language = if languages.is_some() { languages.clone().unwrap()[0].name.clone() } else { - String::default() + String::new() }; let quality = quality.quality.name.clone(); @@ -493,6 +496,7 @@ fn draw_movie_releases(f: &mut Frame<'_, B>, app: &mut App<'_>, cont .style(style_primary()) }, app.is_loading, + true, ); } diff --git a/src/ui/radarr_ui/root_folders_ui.rs b/src/ui/radarr_ui/root_folders_ui.rs index 02baeb4..ba00188 100644 --- a/src/ui/radarr_ui/root_folders_ui.rs +++ b/src/ui/radarr_ui/root_folders_ui.rs @@ -88,6 +88,7 @@ fn draw_root_folders(f: &mut Frame<'_, B>, app: &mut App<'_>, area: .style(style_primary()) }, app.is_loading, + true, ); } diff --git a/src/ui/radarr_ui/system_ui.rs b/src/ui/radarr_ui/system_ui.rs index 77e5c7e..9caf88c 100644 --- a/src/ui/radarr_ui/system_ui.rs +++ b/src/ui/radarr_ui/system_ui.rs @@ -1,4 +1,4 @@ -use crate::ui::utils::{style_primary, style_secondary}; +use crate::ui::utils::{layout_block_top_border, style_help, style_primary, style_secondary}; use crate::ui::{draw_table, TableProps}; use crate::{ app::{radarr::ActiveRadarrBlock, App}, @@ -9,11 +9,12 @@ use crate::{ DrawUi, }, }; -use chrono::DateTime; +use chrono::Utc; use std::ops::Sub; +use tui::layout::Alignment; use tui::style::Modifier; use tui::text::{Span, Text}; -use tui::widgets::{Cell, Row}; +use tui::widgets::{Cell, Paragraph, Row}; use tui::{ backend::Backend, layout::{Constraint, Rect}, @@ -22,6 +23,10 @@ use tui::{ Frame, }; +#[cfg(test)] +#[path = "system_ui_tests.rs"] +mod system_ui_tests; + pub(super) struct SystemUi {} impl DrawUi for SystemUi { @@ -36,8 +41,14 @@ impl DrawUi for SystemUi { } fn draw_system_ui_layout(f: &mut Frame<'_, B>, app: &mut App<'_>, area: Rect) { - let vertical_chunks = - vertical_chunks(vec![Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], area); + let vertical_chunks = vertical_chunks( + vec![ + Constraint::Ratio(1, 2), + Constraint::Ratio(1, 2), + Constraint::Min(2), + ], + area, + ); let horizontal_chunks = horizontal_chunks( vec![Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], @@ -45,8 +56,9 @@ fn draw_system_ui_layout(f: &mut Frame<'_, B>, app: &mut App<'_>, ar ); draw_tasks(f, app, horizontal_chunks[0]); - f.render_widget(title_block("Queue"), horizontal_chunks[1]); + draw_events(f, app, horizontal_chunks[1]); draw_logs(f, app, vertical_chunks[1]); + draw_help(f, app, vertical_chunks[2]); } fn draw_tasks(f: &mut Frame<'_, B>, app: &mut App<'_>, area: Rect) { @@ -62,31 +74,28 @@ fn draw_tasks(f: &mut Frame<'_, B>, app: &mut App<'_>, area: Rect) { "Interval", "Last Execution", "Last Duration", - "Next Duration", + "Next Execution", ], constraints: vec![ Constraint::Percentage(30), Constraint::Percentage(12), - Constraint::Percentage(16), - Constraint::Percentage(16), - Constraint::Percentage(16), + Constraint::Percentage(18), + Constraint::Percentage(18), + Constraint::Percentage(22), ], help: None, }, |task| { - let interval = format!("{} hours", task.interval.as_u64().as_ref().unwrap() / 60); + let interval = convert_to_minutes_hours_days(*task.interval.as_i64().as_ref().unwrap()); let last_duration = &task.last_duration[..8]; - let next_execution = task.next_execution.sub(DateTime::default()).num_minutes(); - let next_execution_string = if next_execution > 60 { - format!("{} hours", next_execution / 60) + let next_execution = + convert_to_minutes_hours_days(task.next_execution.sub(Utc::now()).num_minutes()); + let last_execution = + convert_to_minutes_hours_days(Utc::now().sub(task.last_execution).num_minutes()); + let last_execution_string = if last_execution != "now" { + format!("{} ago", last_execution) } else { - format!("{} minutes", next_execution) - }; - let last_execution = task.last_execution.sub(DateTime::default()).num_minutes(); - let last_execution_string = if last_execution > 60 { - format!("{} hours", last_execution / 60) - } else { - format!("{} minutes", last_execution) + last_execution }; Row::new(vec![ @@ -94,11 +103,68 @@ fn draw_tasks(f: &mut Frame<'_, B>, app: &mut App<'_>, area: Rect) { Cell::from(interval), Cell::from(last_execution_string), Cell::from(last_duration.to_owned()), - Cell::from(next_execution_string), + Cell::from(next_execution), ]) .style(style_primary()) }, app.is_loading, + false, + ); +} + +fn draw_events(f: &mut Frame<'_, B>, app: &mut App<'_>, area: Rect) { + let block = title_block("Events"); + draw_table( + f, + area, + block, + TableProps { + content: &mut app.data.radarr_data.events, + table_headers: vec!["Trigger", "Status", "Name", "Queued", "Started", "Duration"], + constraints: vec![ + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(30), + Constraint::Percentage(16), + Constraint::Percentage(14), + Constraint::Percentage(14), + ], + help: None, + }, + |event| { + let queued = convert_to_minutes_hours_days(Utc::now().sub(event.queued).num_minutes()); + let queued_string = if queued != "now" { + format!("{} ago", queued) + } else { + queued + }; + let started_string = if event.started.is_some() { + let started = + convert_to_minutes_hours_days(Utc::now().sub(event.started.unwrap()).num_minutes()); + + if started != "now" { + format!("{} ago", started) + } else { + started + } + } else { + String::new() + }; + + let duration = &event.duration[..8]; + + Row::new(vec![ + Cell::from(event.trigger.clone()), + Cell::from(event.status.clone()), + Cell::from(event.command_name.clone()), + Cell::from(queued_string), + Cell::from(started_string), + Cell::from(duration.to_owned()), + ]) + .style(style_primary()) + }, + app.is_loading, + false, ); } @@ -134,6 +200,24 @@ fn draw_logs(f: &mut Frame<'_, B>, app: &mut App<'_>, area: Rect) { ); } +fn draw_help(f: &mut Frame<'_, B>, app: &mut App<'_>, area: Rect) { + let mut help_text = Text::from(format!( + " {}", + app + .data + .radarr_data + .main_tabs + .get_active_tab_contextual_help() + .unwrap() + )); + help_text.patch_style(style_help()); + let help_paragraph = Paragraph::new(help_text) + .block(layout_block_top_border()) + .alignment(Alignment::Left); + + f.render_widget(help_paragraph, area); +} + fn determine_log_style_by_level(level: &str) -> Style { match level.to_lowercase().as_str() { "trace" => Style::default().fg(Color::Gray), @@ -145,3 +229,29 @@ fn determine_log_style_by_level(level: &str) -> Style { _ => style_default(), } } + +fn convert_to_minutes_hours_days(time: i64) -> String { + if time < 60 { + if time == 0 { + "now".to_owned() + } else if time == 1 { + format!("{} minute", time) + } else { + format!("{} minutes", time) + } + } else if time / 60 < 24 { + let hours = time / 60; + if hours == 1 { + format!("{} hour", hours) + } else { + format!("{} hours", hours) + } + } else { + let days = time / (60 * 24); + if days == 1 { + format!("{} day", days) + } else { + format!("{} days", days) + } + } +} diff --git a/src/ui/radarr_ui/system_ui_tests.rs b/src/ui/radarr_ui/system_ui_tests.rs new file mode 100644 index 0000000..279905d --- /dev/null +++ b/src/ui/radarr_ui/system_ui_tests.rs @@ -0,0 +1,52 @@ +#[cfg(test)] +mod tests { + use super::super::*; + use pretty_assertions::assert_str_eq; + + #[test] + fn test_determine_log_style_by_level() { + assert_eq!( + determine_log_style_by_level("trace"), + Style::default().fg(Color::Gray) + ); + assert_eq!( + determine_log_style_by_level("debug"), + Style::default().fg(Color::Blue) + ); + assert_eq!(determine_log_style_by_level("info"), style_default()); + assert_eq!(determine_log_style_by_level("warn"), style_secondary()); + assert_eq!(determine_log_style_by_level("error"), style_failure()); + assert_eq!( + determine_log_style_by_level("fatal"), + style_failure().add_modifier(Modifier::BOLD) + ); + assert_eq!(determine_log_style_by_level(""), style_default()); + } + + #[test] + fn test_determine_log_style_by_level_case_insensitive() { + assert_eq!( + determine_log_style_by_level("TrAcE"), + Style::default().fg(Color::Gray) + ); + } + + #[test] + fn test_convert_to_minutes_hours_days_minutes() { + assert_str_eq!(convert_to_minutes_hours_days(0), "now"); + assert_str_eq!(convert_to_minutes_hours_days(1), "1 minute"); + assert_str_eq!(convert_to_minutes_hours_days(2), "2 minutes"); + } + + #[test] + fn test_convert_to_minutes_hours_days_hours() { + assert_str_eq!(convert_to_minutes_hours_days(60), "1 hour"); + assert_str_eq!(convert_to_minutes_hours_days(120), "2 hours"); + } + + #[test] + fn test_convert_to_minutes_hours_days_days() { + assert_str_eq!(convert_to_minutes_hours_days(1440), "1 day"); + assert_str_eq!(convert_to_minutes_hours_days(2880), "2 days"); + } +}