feat: scaffold Readarr support across the state, app, handler, UI, and CLI layers
Promote Route::Readarr from a unit stub to a tuple variant and wire Readarr through every layer, mirroring the Lidarr tree. Readarr appears as a fourth servarr tab and its library view renders the Authors table. Adds ReadarrData and its modals, the tick and context clue modules, the top-level ReadarrHandler, ReadarrUi with the Authors library view, and an empty ReadarrCommand behind ReadarrCliHandler. The default port is 8787. Promoting the route variant compiles cleanly without any of the arms that give it behaviour, so all six of those sites are wired explicitly here: App::on_tick, handlers::handle_events, handle_prompt_toggle, ServarrContextClueProvider, ui(), and verify_config_present_for_cli. Each was checked by removing the arm and confirming its covering assertion then fails. ReadarrEvent and ReadarrCommand are both declared empty. They gain variants alongside the network calls and CLI commands that use them, which keeps each later change to a single function. Adding the tab changes the rendered tab bar, so 13 existing snapshots regenerate. The only delta in each is that single tab bar line. This scaffold adds 24 dead_code warnings, all inside the Readarr tree and none outside it. They clear as the handlers, UI, network events, and CLI commands that consume these items land: block arrays awaiting their accepts implementations and BlockSelectionState seeding, modal and state fields awaiting their handlers, the CLI handler fields awaiting an inhabited command enum, and the top-level handler fields awaiting sub-handlers. The count must strictly decrease from here, so a later increase is a defect rather than transient noise. The 12 pre-existing collapsible_match warnings are unrelated and untouched.
This commit is contained in:
64 files changed
+6418
-22
No files matched your search
@@ -0,0 +1,235 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus};
|
||||
use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS};
|
||||
use crate::ui::DrawUi;
|
||||
use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style};
|
||||
use crate::ui::styles::ManagarrStyle;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::widgets::{Cell, Row};
|
||||
|
||||
#[test]
|
||||
fn test_library_ui_accepts() {
|
||||
for active_readarr_block in ActiveReadarrBlock::iter() {
|
||||
if LIBRARY_BLOCKS.contains(&active_readarr_block) {
|
||||
assert!(
|
||||
LibraryUi::accepts(active_readarr_block.into()),
|
||||
"{active_readarr_block} is not accepted by the LibraryUi"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
!LibraryUi::accepts(active_readarr_block.into()),
|
||||
"{active_readarr_block} should not be accepted by LibraryUi"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorate_row_with_style_unmonitored() {
|
||||
let author = Author::default();
|
||||
let row = Row::new(vec![Cell::from("test".to_owned())]);
|
||||
|
||||
let style = decorate_author_row_with_style(&author, row.clone());
|
||||
|
||||
assert_eq!(style, row.unmonitored());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorate_row_with_style_downloaded_when_ended_and_all_books_present() {
|
||||
let author = Author {
|
||||
monitored: true,
|
||||
status: AuthorStatus::Ended,
|
||||
statistics: Some(AuthorStatistics {
|
||||
book_file_count: 10,
|
||||
total_book_count: 10,
|
||||
..AuthorStatistics::default()
|
||||
}),
|
||||
..Author::default()
|
||||
};
|
||||
let row = Row::new(vec![Cell::from("test".to_owned())]);
|
||||
|
||||
let style = decorate_author_row_with_style(&author, row.clone());
|
||||
|
||||
assert_eq!(style, row.downloaded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorate_row_with_style_missing_when_ended_and_books_are_missing() {
|
||||
let author = Author {
|
||||
monitored: true,
|
||||
status: AuthorStatus::Ended,
|
||||
statistics: Some(AuthorStatistics {
|
||||
book_file_count: 5,
|
||||
total_book_count: 10,
|
||||
..AuthorStatistics::default()
|
||||
}),
|
||||
..Author::default()
|
||||
};
|
||||
let row = Row::new(vec![Cell::from("test".to_owned())]);
|
||||
|
||||
let style = decorate_author_row_with_style(&author, row.clone());
|
||||
|
||||
assert_eq!(style, row.missing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorate_row_with_style_indeterminate_when_ended_and_no_statistics() {
|
||||
let author = Author {
|
||||
monitored: true,
|
||||
status: AuthorStatus::Ended,
|
||||
statistics: None,
|
||||
..Author::default()
|
||||
};
|
||||
let row = Row::new(vec![Cell::from("test".to_owned())]);
|
||||
|
||||
let style = decorate_author_row_with_style(&author, row.clone());
|
||||
|
||||
assert_eq!(style, row.indeterminate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorate_row_with_style_indeterminate_when_ended_and_total_book_count_is_zero() {
|
||||
let author = Author {
|
||||
monitored: true,
|
||||
status: AuthorStatus::Ended,
|
||||
statistics: Some(AuthorStatistics {
|
||||
book_file_count: 0,
|
||||
total_book_count: 0,
|
||||
..AuthorStatistics::default()
|
||||
}),
|
||||
..Author::default()
|
||||
};
|
||||
let row = Row::new(vec![Cell::from("test".to_owned())]);
|
||||
|
||||
let style = decorate_author_row_with_style(&author, row.clone());
|
||||
|
||||
assert_eq!(style, row.missing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorate_row_with_style_unreleased_when_continuing_and_all_books_present() {
|
||||
let author = Author {
|
||||
monitored: true,
|
||||
status: AuthorStatus::Continuing,
|
||||
statistics: Some(AuthorStatistics {
|
||||
book_file_count: 10,
|
||||
total_book_count: 10,
|
||||
..AuthorStatistics::default()
|
||||
}),
|
||||
..Author::default()
|
||||
};
|
||||
let row = Row::new(vec![Cell::from("test".to_owned())]);
|
||||
|
||||
let style = decorate_author_row_with_style(&author, row.clone());
|
||||
|
||||
assert_eq!(style, row.unreleased());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorate_row_with_style_missing_when_continuing_and_books_are_missing() {
|
||||
let author = Author {
|
||||
monitored: true,
|
||||
status: AuthorStatus::Continuing,
|
||||
statistics: Some(AuthorStatistics {
|
||||
book_file_count: 5,
|
||||
total_book_count: 10,
|
||||
..AuthorStatistics::default()
|
||||
}),
|
||||
..Author::default()
|
||||
};
|
||||
let row = Row::new(vec![Cell::from("test".to_owned())]);
|
||||
|
||||
let style = decorate_author_row_with_style(&author, row.clone());
|
||||
|
||||
assert_eq!(style, row.missing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorate_row_with_style_indeterminate_when_continuing_and_no_statistics() {
|
||||
let author = Author {
|
||||
monitored: true,
|
||||
status: AuthorStatus::Continuing,
|
||||
statistics: None,
|
||||
..Author::default()
|
||||
};
|
||||
let row = Row::new(vec![Cell::from("test".to_owned())]);
|
||||
|
||||
let style = decorate_author_row_with_style(&author, row.clone());
|
||||
|
||||
assert_eq!(style, row.indeterminate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decorate_row_with_style_defaults_to_indeterminate_for_deleted_status() {
|
||||
let author = Author {
|
||||
monitored: true,
|
||||
status: AuthorStatus::Deleted,
|
||||
..Author::default()
|
||||
};
|
||||
let row = Row::new(vec![Cell::from("test".to_owned())]);
|
||||
|
||||
let style = decorate_author_row_with_style(&author, row.clone());
|
||||
|
||||
assert_eq!(style, row.indeterminate());
|
||||
}
|
||||
|
||||
mod snapshot_tests {
|
||||
use crate::app::App;
|
||||
use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
|
||||
use rstest::rstest;
|
||||
|
||||
use crate::ui::DrawUi;
|
||||
use crate::ui::readarr_ui::library::LibraryUi;
|
||||
use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app};
|
||||
|
||||
#[rstest]
|
||||
fn test_library_ui_renders(
|
||||
#[values(
|
||||
ActiveReadarrBlock::Authors,
|
||||
ActiveReadarrBlock::AuthorsSortPrompt,
|
||||
ActiveReadarrBlock::SearchAuthors,
|
||||
ActiveReadarrBlock::SearchAuthorsError,
|
||||
ActiveReadarrBlock::FilterAuthors,
|
||||
ActiveReadarrBlock::FilterAuthorsError
|
||||
)]
|
||||
active_readarr_block: ActiveReadarrBlock,
|
||||
) {
|
||||
let mut app = App::test_default_fully_populated();
|
||||
app.push_navigation_stack(active_readarr_block.into());
|
||||
|
||||
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
|
||||
LibraryUi::draw(f, app, f.area());
|
||||
});
|
||||
|
||||
insta::assert_snapshot!(format!("readarr_library_{active_readarr_block}"), output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_library_ui_renders_loading() {
|
||||
let mut app = App::test_default_fully_populated();
|
||||
app.is_loading = true;
|
||||
app.push_navigation_stack(ActiveReadarrBlock::Authors.into());
|
||||
|
||||
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
|
||||
LibraryUi::draw(f, app, f.area());
|
||||
});
|
||||
|
||||
insta::assert_snapshot!(output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_library_ui_renders_empty() {
|
||||
let mut app = App::test_default();
|
||||
app.push_navigation_stack(ActiveReadarrBlock::Authors.into());
|
||||
|
||||
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
|
||||
LibraryUi::draw(f, app, f.area());
|
||||
});
|
||||
|
||||
insta::assert_snapshot!(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Rect},
|
||||
widgets::{Cell, Row},
|
||||
};
|
||||
|
||||
use crate::ui::widgets::managarr_table::ManagarrTable;
|
||||
use crate::utils::convert_to_gb;
|
||||
use crate::{
|
||||
app::App,
|
||||
models::{
|
||||
Route,
|
||||
readarr_models::{Author, AuthorStatus},
|
||||
servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS},
|
||||
},
|
||||
ui::{
|
||||
DrawUi,
|
||||
styles::ManagarrStyle,
|
||||
utils::{get_width_from_percentage, layout_block_top_border},
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library_ui_tests.rs"]
|
||||
mod library_ui_tests;
|
||||
|
||||
pub(super) struct LibraryUi;
|
||||
|
||||
impl DrawUi for LibraryUi {
|
||||
fn accepts(route: Route) -> bool {
|
||||
if let Route::Readarr(active_readarr_block, _) = route {
|
||||
return LIBRARY_BLOCKS.contains(&active_readarr_block);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
|
||||
draw_library(f, app, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
|
||||
if let Route::Readarr(active_readarr_block, _) = app.get_current_route() {
|
||||
let current_selection = if !app.data.readarr_data.authors.items.is_empty() {
|
||||
app.data.readarr_data.authors.current_selection().clone()
|
||||
} else {
|
||||
Author::default()
|
||||
};
|
||||
let quality_profile_map = &app.data.readarr_data.quality_profile_map;
|
||||
let metadata_profile_map = &app.data.readarr_data.metadata_profile_map;
|
||||
let tags_map = &app.data.readarr_data.tags_map;
|
||||
let content = Some(&mut app.data.readarr_data.authors);
|
||||
|
||||
let authors_table_row_mapping = |author: &Author| {
|
||||
author.author_name.scroll_left_or_reset(
|
||||
get_width_from_percentage(area, 25),
|
||||
*author == current_selection,
|
||||
app.should_text_scroll,
|
||||
);
|
||||
let monitored = if author.monitored { "🏷" } else { "" };
|
||||
let author_type = author.author_type.clone().unwrap_or_default();
|
||||
let size = author
|
||||
.statistics
|
||||
.as_ref()
|
||||
.map_or(0f64, |stats| convert_to_gb(stats.size_on_disk));
|
||||
let quality_profile = quality_profile_map
|
||||
.get_by_left(&author.quality_profile_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let metadata_profile = metadata_profile_map
|
||||
.get_by_left(&author.metadata_profile_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let books = author
|
||||
.statistics
|
||||
.as_ref()
|
||||
.map_or(0, |stats| stats.book_count);
|
||||
let tags = author
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag_id| {
|
||||
let id = tag_id.as_i64()?;
|
||||
tags_map.get_by_left(&id).cloned()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
decorate_author_row_with_style(
|
||||
author,
|
||||
Row::new(vec![
|
||||
Cell::from(author.author_name.to_string()),
|
||||
Cell::from(author_type),
|
||||
Cell::from(author.status.to_display_str()),
|
||||
Cell::from(quality_profile),
|
||||
Cell::from(metadata_profile),
|
||||
Cell::from(books.to_string()),
|
||||
Cell::from(format!("{size:.2} GB")),
|
||||
Cell::from(monitored.to_owned()),
|
||||
Cell::from(tags),
|
||||
]),
|
||||
)
|
||||
};
|
||||
let authors_table = ManagarrTable::new(content, authors_table_row_mapping)
|
||||
.block(layout_block_top_border())
|
||||
.loading(app.is_loading)
|
||||
.sorting(active_readarr_block == ActiveReadarrBlock::AuthorsSortPrompt)
|
||||
.searching(active_readarr_block == ActiveReadarrBlock::SearchAuthors)
|
||||
.filtering(active_readarr_block == ActiveReadarrBlock::FilterAuthors)
|
||||
.search_produced_empty_results(active_readarr_block == ActiveReadarrBlock::SearchAuthorsError)
|
||||
.filter_produced_empty_results(active_readarr_block == ActiveReadarrBlock::FilterAuthorsError)
|
||||
.headers([
|
||||
"Name",
|
||||
"Type",
|
||||
"Status",
|
||||
"Quality Profile",
|
||||
"Metadata Profile",
|
||||
"Books",
|
||||
"Size",
|
||||
"Monitored",
|
||||
"Tags",
|
||||
])
|
||||
.constraints([
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(9),
|
||||
Constraint::Percentage(8),
|
||||
Constraint::Percentage(13),
|
||||
Constraint::Percentage(13),
|
||||
Constraint::Percentage(7),
|
||||
Constraint::Percentage(8),
|
||||
Constraint::Percentage(6),
|
||||
Constraint::Percentage(11),
|
||||
]);
|
||||
|
||||
if [
|
||||
ActiveReadarrBlock::SearchAuthors,
|
||||
ActiveReadarrBlock::FilterAuthors,
|
||||
]
|
||||
.contains(&active_readarr_block)
|
||||
{
|
||||
authors_table.show_cursor(f, area);
|
||||
}
|
||||
|
||||
f.render_widget(authors_table, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn decorate_author_row_with_style<'a>(author: &Author, row: Row<'a>) -> Row<'a> {
|
||||
if !author.monitored {
|
||||
return row.unmonitored();
|
||||
}
|
||||
|
||||
match author.status {
|
||||
AuthorStatus::Ended => {
|
||||
if let Some(ref stats) = author.statistics {
|
||||
return if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 {
|
||||
row.downloaded()
|
||||
} else {
|
||||
row.missing()
|
||||
};
|
||||
}
|
||||
row.indeterminate()
|
||||
}
|
||||
AuthorStatus::Continuing => {
|
||||
if let Some(ref stats) = author.statistics {
|
||||
return if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 {
|
||||
row.unreleased()
|
||||
} else {
|
||||
row.missing()
|
||||
};
|
||||
}
|
||||
row.indeterminate()
|
||||
}
|
||||
_ => row.indeterminate(),
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: src/ui/readarr_ui/library/library_ui_tests.rs
|
||||
expression: output
|
||||
---
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/ui/readarr_ui/library/library_ui_tests.rs
|
||||
expression: output
|
||||
---
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Loading ...
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: src/ui/readarr_ui/library/library_ui_tests.rs
|
||||
expression: output
|
||||
---
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags
|
||||
=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
---
|
||||
source: src/ui/readarr_ui/library/library_ui_tests.rs
|
||||
expression: output
|
||||
---
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Name Type Status Quality Profile Metadata Profile Books Size Monitored Tags
|
||||
=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭───────────────────────────────╮
|
||||
│Name │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰───────────────────────────────╯
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: src/ui/readarr_ui/library/library_ui_tests.rs
|
||||
expression: output
|
||||
---
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags
|
||||
=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭───────────────── Filter ──────────────────╮
|
||||
│author filter │
|
||||
╰─────────────────────────────────────────────╯
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
---
|
||||
source: src/ui/readarr_ui/library/library_ui_tests.rs
|
||||
expression: output
|
||||
---
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags
|
||||
=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭─────────────── Error ───────────────╮
|
||||
│The given filter produced empty results│
|
||||
│ │
|
||||
╰───────────────────────────────────────╯
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: src/ui/readarr_ui/library/library_ui_tests.rs
|
||||
expression: output
|
||||
---
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags
|
||||
=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭───────────────── Search ──────────────────╮
|
||||
│author search │
|
||||
╰─────────────────────────────────────────────╯
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
---
|
||||
source: src/ui/readarr_ui/library/library_ui_tests.rs
|
||||
expression: output
|
||||
---
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Name ▼ Type Status Quality Profile Metadata Profile Books Size Monitored Tags
|
||||
=> Test Author Continuing Standard Standard 0 0.00 GB 🏷 alex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭─────────────── Error ───────────────╮
|
||||
│ No items found matching search │
|
||||
│ │
|
||||
╰───────────────────────────────────────╯
|
||||
Reference in new issue
Block a user