feat: add insta snapshot testing for TUI components

- Add insta dev-dependency for visual regression testing
- Create lib.rs to expose modules for integration tests
- Add snapshot tests for progress_bar, topbar, library, help modal
- Add unit tests for LibraryState navigation
- Move all tests to tests/ directory (proper Rust convention)
- Make build.rs skip proto compilation when protoc unavailable
- Add docs/testing-possible-solutions.md with testing strategies
This commit is contained in:
Alexander
2026-05-09 11:35:10 +02:00
parent f7660436c2
commit 5bee7092d3
25 changed files with 799 additions and 115 deletions
Generated
+36
View File
@@ -231,6 +231,17 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "console"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
dependencies = [
"encode_unicode",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "crossterm"
version = "0.28.1"
@@ -296,6 +307,12 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -612,6 +629,18 @@ dependencies = [
"rustversion",
]
[[package]]
name = "insta"
version = "1.47.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e"
dependencies = [
"console",
"once_cell",
"similar",
"tempfile",
]
[[package]]
name = "instability"
version = "0.3.12"
@@ -1179,6 +1208,12 @@ dependencies = [
"libc",
]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "slab"
version = "0.4.12"
@@ -1489,6 +1524,7 @@ version = "0.1.0"
dependencies = [
"color-eyre",
"crossterm",
"insta",
"nix",
"prost",
"ratatui",
+3
View File
@@ -15,5 +15,8 @@ tonic = "0.12"
prost = "0.13"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
[dev-dependencies]
insta = "1.40.0"
[build-dependencies]
tonic-build = "0.12"
+12 -7
View File
@@ -1,17 +1,22 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
let proto_root = "../music-agregator/proto";
let proto_file = format!("{}/music_agregator/v1/music_agregator.proto", proto_root);
let proto_file_exists = std::path::Path::new(&proto_file).exists();
let protoc_available = std::process::Command::new("protoc")
.arg("--version")
.output()
.is_ok();
if !proto_file_exists || !protoc_available {
return Ok(());
}
tonic_build::configure()
.build_server(false)
.build_client(true)
.out_dir("src/proto")
.compile_protos(
&[format!(
"{}/music_agregator/v1/music_agregator.proto",
proto_root
)],
&[proto_root],
)?;
.compile_protos(&[proto_file], &[proto_root])?;
Ok(())
}
+186
View File
@@ -0,0 +1,186 @@
# Testing Strategies for Ratatui TUI Applications
Research summary for testing the ui-agregator TUI application.
## 1. TestBackend + Buffer Assertions (Built-in)
Ratatui provides `TestBackend` - an in-memory terminal mock for testing without a real TTY.
```rust
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn test_widget_renders() {
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal.draw(|frame| {
frame.render_widget(MyWidget::new(), frame.area());
}).unwrap();
terminal.backend().assert_buffer_lines([
"Expected line 1",
"Expected line 2",
]);
}
```
**Best for**: Unit testing widgets, layout logic, pure rendering functions.
**Key Methods**:
- `TestBackend::new(width, height)` - Create in-memory terminal
- `backend.buffer()` - Access rendered buffer
- `backend.assert_buffer(&expected)` - Assert buffer equality
- `backend.assert_buffer_lines(lines)` - Assert against string lines
---
## 2. Snapshot Testing with `insta` (Visual Regression)
Captures rendered output to `.snap` files; detects unexpected visual changes.
**Official Recipe**: https://ratatui.rs/recipes/testing/snapshots/
### Setup
```toml
[dev-dependencies]
insta = "1.40.0"
```
```bash
cargo install cargo-insta
```
### Usage Pattern
```rust
#[test]
fn test_library_view_snapshot() {
let mut terminal = Terminal::new(TestBackend::new(80, 40)).unwrap();
let mut state = LibraryState::new(vec![/* test data */]);
terminal.draw(|f| render_library(f, f.area(), &mut state)).unwrap();
insta::assert_snapshot!(terminal.backend());
}
```
### Workflow
1. **First run**: Creates `.snap` file in `snapshots/` directory
2. **Subsequent runs**: Compares against snapshot
3. **Review changes**: `cargo insta review` - interactive diff viewer
4. **Update snapshots**: `cargo insta test --review`
**Best for**: Catching visual regressions, complex layouts, multi-component views.
**Note**: Color/style information is not currently captured in snapshots.
---
## 3. Direct Unit Tests (State Logic, No Rendering)
Test state machines and business logic without rendering.
```rust
#[test]
fn test_library_navigation() {
let mut state = LibraryState::new(test_artists());
state.move_down();
assert_eq!(state.selected_artist_index(), Some(1));
state.focus_right();
assert_eq!(state.focus, LibraryFocus::Albums);
}
```
**Best for**: State transitions, event handling logic, data transformations.
---
## 4. PTY-Based Integration Tests (`ratatui-testlib`)
Real terminal emulation with event simulation for end-to-end testing.
```toml
[dev-dependencies]
ratatui-testlib = "0.1"
```
```rust
#[test]
fn test_tab_switching_integration() -> Result<()> {
let mut harness = TuiTestHarness::new(80, 24)?;
harness.spawn(CommandBuilder::new("./ui-agregator"))?;
harness.wait_for(|s| s.contents().contains("Library"))?;
harness.send_key(KeyCode::Char('2'))?;
harness.wait_for(|s| s.contents().contains("Wanted"))?;
Ok(())
}
```
**Best for**: Full E2E scenarios, keyboard/mouse flow testing, CI validation.
---
## 5. Property-Based Testing (`proptest`)
Test invariants across random inputs - excellent for coordinate math and bounds checking.
```toml
[dev-dependencies]
proptest = "1.4"
```
```rust
proptest! {
#[test]
fn click_never_panics(x in 0u16..200, y in 0u16..100) {
let mut app = App::new();
app.size = Rect::new(0, 0, 80, 24);
app.handle_click(x, y, MouseButton::Left);
}
}
```
**Best for**: Click handling, scroll bounds, layout calculations.
---
## Testing Plan for ui-agregator
| Component | Strategy | Priority |
|-----------|----------|----------|
| `LibraryState` navigation | Unit tests | High |
| `NotificationManager` TTL/history | Unit tests | High |
| `App.handle_click()` routing | Unit + proptest | High |
| Tab views (wanted, queue, history) | Snapshot tests (insta) | Medium |
| `progress_bar.rs` | Already has 3 tests | Done |
| Modals (help, quit) | Snapshot tests | Medium |
| gRPC response → model conversion | Unit tests | Medium |
| Full app E2E flows | ratatui-testlib | Low |
---
## Recommended Dependencies
```toml
[dev-dependencies]
insta = "1.40.0"
rstest = "0.22" # Parameterized tests
proptest = "1.4" # Property-based testing
# ratatui-testlib = "0.1" # Optional: PTY integration tests
```
---
## Resources
- **Official Testing Docs**: https://ratatui.rs/recipes/testing/
- **Snapshot Testing Recipe**: https://ratatui.rs/recipes/testing/snapshots/
- **TestBackend API**: https://docs.rs/ratatui/latest/ratatui/backend/struct.TestBackend.html
- **insta Documentation**: https://insta.rs/docs/
- **ratatui-testlib**: https://docs.rs/ratatui-testlib/
+30 -9
View File
@@ -192,8 +192,8 @@ impl App {
}
fn render_notifications_dropdown(&mut self, frame: &mut Frame) {
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use crate::ui::notifications::render_notification_item;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
let history = self.notifications.history();
if history.is_empty() {
@@ -206,10 +206,17 @@ impl App {
if let Some(expanded_id) = self.notifications_expanded {
if let Some(notif) = history.iter().find(|n| n.id == expanded_id) {
let detail_lines = notif.detail.as_ref().map(|d| d.lines().count()).unwrap_or(1);
let detail_lines = notif
.detail
.as_ref()
.map(|d| d.lines().count())
.unwrap_or(1);
let dropdown_height = (6 + detail_lines as u16).min(20);
let x = self.notifications_btn_area.x.saturating_sub(dropdown_width - self.notifications_btn_area.width);
let x = self
.notifications_btn_area
.x
.saturating_sub(dropdown_width - self.notifications_btn_area.width);
let y = self.topbar_area.y + 1;
let dropdown_area = Rect::new(
@@ -230,8 +237,8 @@ impl App {
let inner = block.inner(dropdown_area);
frame.render_widget(block, dropdown_area);
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::style::{Style, Modifier};
let elapsed = notif.created_at.elapsed().as_secs();
let time_str = if elapsed < 60 {
@@ -261,7 +268,10 @@ impl App {
if let Some(detail) = &notif.detail {
lines.push(Line::from(""));
for line in detail.lines() {
lines.push(Line::from(Span::styled(line, Style::default().fg(theme::FG2))));
lines.push(Line::from(Span::styled(
line,
Style::default().fg(theme::FG2),
)));
}
}
@@ -274,7 +284,10 @@ impl App {
let visible_count = history.len().min(max_items);
let dropdown_height = (visible_count as u16 * item_height) + 2;
let x = self.notifications_btn_area.x.saturating_sub(dropdown_width - self.notifications_btn_area.width);
let x = self
.notifications_btn_area
.x
.saturating_sub(dropdown_width - self.notifications_btn_area.width);
let y = self.topbar_area.y + 1;
let dropdown_area = Rect::new(
@@ -297,7 +310,13 @@ impl App {
let start_idx = self.notifications_scroll;
let end_idx = (start_idx + max_items).min(history.len());
for (i, notif) in history.iter().rev().skip(start_idx).take(end_idx - start_idx).enumerate() {
for (i, notif) in history
.iter()
.rev()
.skip(start_idx)
.take(end_idx - start_idx)
.enumerate()
{
let item_y = inner.y + (i as u16 * item_height);
let item_area = Rect::new(inner.x, item_y, inner.width, item_height);
render_notification_item(frame, item_area, notif);
@@ -502,7 +521,8 @@ impl App {
if rel_y < tracks_start_row {
if let Some(artist) = self.library.selected_artist()
&& album_row < artist.albums.len() {
&& album_row < artist.albums.len()
{
self.library.album_state.select(Some(album_row));
self.library.track_state.select(Some(0));
self.library.focus = LibraryFocus::Albums;
@@ -510,7 +530,8 @@ impl App {
} else {
let track_row = rel_y - tracks_start_row;
if let Some(album) = self.library.selected_album()
&& track_row < album.total as usize {
&& track_row < album.total as usize
{
self.library.track_state.select(Some(track_row));
self.library.focus = LibraryFocus::Tracks;
}
+9 -9
View File
@@ -17,7 +17,10 @@ pub enum GrpcRequest {
#[allow(dead_code, clippy::large_enum_variant)]
pub enum GrpcResponse {
Artists(Vec<ArtistSummary>),
Album { album: AlbumDetail, tracks: Vec<TrackDetail> },
Album {
album: AlbumDetail,
tracks: Vec<TrackDetail>,
},
Error(String),
}
@@ -46,14 +49,11 @@ impl GrpcClient {
&mut self,
album_id: String,
) -> Result<(AlbumDetail, Vec<TrackDetail>), tonic::Status> {
let response = self
.music
.get_album(GetAlbumRequest { album_id })
.await?;
let response = self.music.get_album(GetAlbumRequest { album_id }).await?;
let inner = response.into_inner();
let album = inner.album.ok_or_else(|| {
tonic::Status::not_found("Album not found in response")
})?;
let album = inner
.album
.ok_or_else(|| tonic::Status::not_found("Album not found in response"))?;
Ok((album, inner.tracks))
}
}
@@ -84,7 +84,7 @@ pub fn spawn_grpc_worker(
GrpcRequest::GetAlbum { album_id } => match client.get_album(album_id).await {
Ok((album, tracks)) => GrpcResponse::Album { album, tracks },
Err(e) => GrpcResponse::Error(e.to_string()),
}
},
};
if resp_tx.send(response).await.is_err() {
+7
View File
@@ -0,0 +1,7 @@
pub mod app;
pub mod config;
pub mod data;
pub mod grpc;
pub mod proto;
pub mod theme;
pub mod ui;
+3 -11
View File
@@ -13,17 +13,9 @@ use crossterm::{
};
use ratatui::prelude::*;
mod app;
mod config;
mod data;
mod grpc;
mod proto;
mod theme;
mod ui;
use app::App;
use config::Config;
use grpc::{GrpcRequest, spawn_grpc_worker};
use ui_agregator::app::App;
use ui_agregator::config::Config;
use ui_agregator::grpc::{GrpcRequest, spawn_grpc_worker};
const TICK_RATE: Duration = Duration::from_millis(100);
+19 -8
View File
@@ -75,21 +75,24 @@ impl LibraryState {
match self.focus {
LibraryFocus::Artists => {
if let Some(i) = self.artist_state.selected()
&& i > 0 {
&& i > 0
{
self.artist_state.select(Some(i - 1));
self.reset_album_selection();
}
}
LibraryFocus::Albums => {
if let Some(i) = self.album_state.selected()
&& i > 0 {
&& i > 0
{
self.album_state.select(Some(i - 1));
self.reset_track_selection();
}
}
LibraryFocus::Tracks => {
if let Some(i) = self.track_state.selected()
&& i > 0 {
&& i > 0
{
self.track_state.select(Some(i - 1));
}
}
@@ -101,7 +104,8 @@ impl LibraryState {
LibraryFocus::Artists => {
let max = self.artists.len().saturating_sub(1);
if let Some(i) = self.artist_state.selected()
&& i < max {
&& i < max
{
self.artist_state.select(Some(i + 1));
self.reset_album_selection();
}
@@ -112,7 +116,8 @@ impl LibraryState {
.map(|a| a.albums.len().saturating_sub(1))
.unwrap_or(0);
if let Some(i) = self.album_state.selected()
&& i < max {
&& i < max
{
self.album_state.select(Some(i + 1));
self.reset_track_selection();
}
@@ -120,7 +125,8 @@ impl LibraryState {
LibraryFocus::Tracks => {
let max = self.track_count().saturating_sub(1);
if let Some(i) = self.track_state.selected()
&& i < max {
&& i < max
{
self.track_state.select(Some(i + 1));
}
}
@@ -386,7 +392,8 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let selected_artist_idx = state.artist_state.selected();
if let Some(idx) = selected_artist_idx
&& let Some(artist) = state.artists.get(idx) {
&& let Some(artist) = state.artists.get(idx)
{
let albums = artist.albums.clone();
let focus = state.focus;
render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state);
@@ -607,7 +614,11 @@ impl LibraryState {
pub fn load_tracks_from_cache(&mut self, album_id: &str) {
if let Some(tracks) = self.tracks_cache.get(album_id) {
self.tracks = tracks.clone();
self.track_state.select(if self.tracks.is_empty() { None } else { Some(0) });
self.track_state.select(if self.tracks.is_empty() {
None
} else {
Some(0)
});
}
}
+12 -3
View File
@@ -175,7 +175,10 @@ impl NotificationManager {
} else {
detail.clone()
};
lines.push(Line::from(Span::styled(d, Style::default().fg(theme::GRAY))));
lines.push(Line::from(Span::styled(
d,
Style::default().fg(theme::GRAY),
)));
}
let para = Paragraph::new(lines);
@@ -225,7 +228,10 @@ pub fn render_notification_item(frame: &mut Frame, area: Rect, notif: &Notificat
} else {
detail.clone()
};
lines.push(Line::from(Span::styled(d, Style::default().fg(theme::GRAY))));
lines.push(Line::from(Span::styled(
d,
Style::default().fg(theme::GRAY),
)));
} else {
let max_len = inner.width.saturating_sub(2) as usize;
let title = if notif.title.len() > max_len {
@@ -233,7 +239,10 @@ pub fn render_notification_item(frame: &mut Frame, area: Rect, notif: &Notificat
} else {
notif.title.clone()
};
lines.push(Line::from(Span::styled(title, Style::default().fg(theme::GRAY))));
lines.push(Line::from(Span::styled(
title,
Style::default().fg(theme::GRAY),
)));
}
let para = Paragraph::new(lines);
+2 -1
View File
@@ -80,7 +80,8 @@ impl Widget for Pane<'_> {
block.render(area, buf);
if let Some(footer) = self.footer
&& area.height > 2 {
&& area.height > 2
{
let footer_y = area.y + area.height - 1;
let footer_x = area.x + 2;
let footer_width = area.width.saturating_sub(4);
-23
View File
@@ -37,26 +37,3 @@ pub fn progress_bar(have: u16, total: u16, width: usize, status: AlbumStatus) ->
Span::styled(empty_str, Style::default().fg(theme::BG3)),
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_progress_bar_full() {
let line = progress_bar(10, 10, 10, AlbumStatus::Complete);
assert_eq!(line.spans.len(), 2);
}
#[test]
fn test_progress_bar_empty() {
let line = progress_bar(0, 10, 10, AlbumStatus::Wanted);
assert_eq!(line.spans.len(), 2);
}
#[test]
fn test_progress_bar_partial() {
let line = progress_bar(5, 10, 10, AlbumStatus::Partial);
assert_eq!(line.spans.len(), 2);
}
}
+2 -1
View File
@@ -71,7 +71,8 @@ pub fn render_calendar(frame: &mut Frame, area: Rect, calendar: &[CalendarEntry]
.iter()
.filter(|e| {
if let Some(day_str) = e.date.split('-').nth(2)
&& let Ok(day) = day_str.parse::<u8>() {
&& let Ok(day) = day_str.parse::<u8>()
{
let month_match = e.date.contains(&format!("{:04}-{:02}", year, month));
return month_match && day == d;
}
@@ -0,0 +1,34 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
" "
" ┌─ Keybindings · evil-mode (Doom Emacs) ───────────────────────────────────────────────────────┐ "
" │Motion · char/line Search & jumps SPC leader (Doom) │ "
" │h j k l left / down / up / pat filter library SPC SPC M-x command │ "
" │w / W next word / WORD ? pat search backward SPC b +buffer (tabs) │ "
" │b / B prev word / WORD n / N next / prev match SPC f +file / library │ "
" │e / E end of word / WOR* / # search word fwd/baSPC s +search │ "
" │ge / gE back to end of (WC-o / C-i jumplist back / fwSPC w +window / pane │ "
" │0 / ^ line start (focusm{a-z} set mark SPC t +toggle / theme │ "
" │$ line end (focus r'{a-z} jump to mark line SPC n +notifications │ "
" │{N}<motion> repeat motion N t`{a-z} jump to mark exactSPC a +actions / artist│ "
" │ '' jump to last positSPC q +quit │ "
" │Motion · file/page SPC h +help │ "
" │g g first line Center · z_ SPC l/w/h/c → tab quick │ "
" │G last line z z / z . center cursor │ "
" │{N} G go to line N z t cursor → top Modes & ex commands │ "
" │g t / g T next / prev tab z b / z - cursor → bottom :w / :sync save library │ "
" │C-d / C-u ½ page down/up :q quit │ "
" │C-f / C-b page down/up :theme dark | light │ "
" │C-e / C-y scroll line down/ a · t · s · r add·toggle·search│ "
" │H / M / L viewport top / mi 1‥6 switch tab │ "
" │{ / } paragraph back/fw Enter / Esc open / back │ "
" │[[ / ]] section back/fwd ? this help │ "
" │[c / ]c prev / next chang │ "
" │ │ "
" │ │ "
" │ │ "
" │harmony · v0.4.2 · canonical evil-mode bindings (Vim/Doom Emacs) │ "
" └──────────────────────────────────────────────────────────────────────────────────────────────┘ "
" "
@@ -0,0 +1,24 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
" "
" ┌─ Keybindings · evil-mode (Doom Emacs) ───────────────┐ "
" │Motion · char/lineSearch & jumps SPC leader (Doom) │ "
" │h j k l left/ pat filtSPC SPC M-x │ "
" │w / W next? pat searSPC b +buf│ "
" │b / B prevn / N nextSPC f +fil│ "
" │e / E end * / # searSPC s +sea│ "
" │ge / gE backC-o / C-i jumpSPC w +win│ "
" │0 / ^ linem{a-z} set SPC t +tog│ "
" │$ line'{a-z} jumpSPC n +not│ "
" │{N}<motion> repe`{a-z} jumpSPC a +act│ "
" │ '' jumpSPC q +qui│ "
" │Motion · file/page SPC h +hel│ "
" │g g firsCenter · z_ SPC l/w/h/c → ta│ "
" │G lastz z / z . cent │ "
" │{N} G go tz t cursModes & ex command│ "
" │g t / g T nextz b / z - curs:w / :sync save│ "
" │harmony · v0.4.2 · canonical evil-mode bindings (Vim/D│ "
" └──────────────────────────────────────────────────────┘ "
" "
@@ -0,0 +1,34 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 2 ]──────────────┐┌─[ Detail · UK · Alternative ]────────────────────────────────────┐"
"│◐ Radiohead 17/22 ││Radiohead │"
"│! Pink Floyd 0/10 ││ │"
"│ ││status ● Monitored path /music/Radiohead │"
"│ ││quality FLAC size 2.5 GB │"
"│ ││albums 2 tracks 17 / 22 │"
"│ ││ │"
"│ ││─ albums ─ 2 releases │"
"│ ││● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC │"
"│ ││◐ Kid A [Album] 2000 ▰▰▰▰▰▱▱▱▱▱ 5/10 FLAC │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││─ tracks · OK Computer ─ 12/12 │"
"│ ││(no album selected) │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└──────────────────────────────┘└──────────────────────────────────────────────────────────────────┘"
@@ -0,0 +1,34 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 0 ]──────────────┐┌─[ Detail · ]────────────────────────────────────────────────────┐"
"│ ││No artist selected │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└──────────────────────────────┘└──────────────────────────────────────────────────────────────────┘"
@@ -0,0 +1,34 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 2 ]──────────────┐┌─[ Detail · UK · Alternative ]────────────────────────────────────┐"
"│◐ Radiohead 17/22 ││Radiohead │"
"│! Pink Floyd 0/10 ││ │"
"│ ││status ● Monitored path /music/Radiohead │"
"│ ││quality FLAC size 2.5 GB │"
"│ ││albums 2 tracks 17 / 22 │"
"│ ││ │"
"│ ││─ albums ─ 2 releases │"
"│ ││● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC │"
"│ ││◐ Kid A [Album] 2000 ▰▰▰▰▰▱▱▱▱▱ 5/10 FLAC │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││─ tracks · OK Computer ─ 12/12 │"
"│ ││(no album selected) │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└──────────────────────────────┘└──────────────────────────────────────────────────────────────────┘"
@@ -0,0 +1,5 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"▰▰▰▰▰▰▰▰▰▰ "
@@ -0,0 +1,5 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"▱▱▱▱▱▱▱▱▱▱ "
@@ -0,0 +1,5 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"▰▰▰▰▰▱▱▱▱▱ "
@@ -0,0 +1,5 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
" ▲ harmony Library Wanted Queue History Calendar Settings ● Notifications "
@@ -0,0 +1,5 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
" ▲ harmony Library Wanted Queue History Calendar Settings ● Notifications (5) "
@@ -0,0 +1,5 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
" ▲ harmony Library Wanted 12 Queue 5 History Calendar Settings ● Notifications (3) "
+245
View File
@@ -0,0 +1,245 @@
use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph};
use ui_agregator::app::Tab;
use ui_agregator::data::{Album, AlbumStatus, Artist, MonitorState};
use ui_agregator::ui::library::{LibraryFocus, LibraryState, render_library};
use ui_agregator::ui::modals::render_help_modal;
use ui_agregator::ui::progress_bar::progress_bar;
use ui_agregator::ui::topbar::render_topbar;
fn test_artists() -> Vec<Artist> {
vec![
Artist {
id: "1".to_string(),
name: "Radiohead".to_string(),
country: "UK".to_string(),
genres: vec!["Alternative".to_string()],
monitor_state: MonitorState::Monitored,
path: "/music/Radiohead".to_string(),
quality: "FLAC".to_string(),
size_gb: 2.5,
albums: vec![
Album {
id: "a1".to_string(),
title: "OK Computer".to_string(),
year: 1997,
album_type: "Album".to_string(),
monitored: true,
total: 12,
have: 12,
quality: "FLAC".to_string(),
status: AlbumStatus::Complete,
},
Album {
id: "a2".to_string(),
title: "Kid A".to_string(),
year: 2000,
album_type: "Album".to_string(),
monitored: true,
total: 10,
have: 5,
quality: "FLAC".to_string(),
status: AlbumStatus::Partial,
},
],
},
Artist {
id: "2".to_string(),
name: "Pink Floyd".to_string(),
country: "UK".to_string(),
genres: vec!["Progressive Rock".to_string()],
monitor_state: MonitorState::Monitored,
path: "/music/Pink Floyd".to_string(),
quality: "FLAC".to_string(),
size_gb: 5.2,
albums: vec![Album {
id: "a3".to_string(),
title: "The Dark Side of the Moon".to_string(),
year: 1973,
album_type: "Album".to_string(),
monitored: true,
total: 10,
have: 0,
quality: "".to_string(),
status: AlbumStatus::Wanted,
}],
},
]
}
mod progress_bar_snapshots {
use super::*;
#[test]
fn complete() {
let mut terminal = Terminal::new(TestBackend::new(12, 1)).unwrap();
terminal
.draw(|f| {
let bar = progress_bar(10, 10, 10, AlbumStatus::Complete);
f.render_widget(Paragraph::new(bar), f.area());
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn partial() {
let mut terminal = Terminal::new(TestBackend::new(12, 1)).unwrap();
terminal
.draw(|f| {
let bar = progress_bar(5, 10, 10, AlbumStatus::Partial);
f.render_widget(Paragraph::new(bar), f.area());
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn empty() {
let mut terminal = Terminal::new(TestBackend::new(12, 1)).unwrap();
terminal
.draw(|f| {
let bar = progress_bar(0, 10, 10, AlbumStatus::Wanted);
f.render_widget(Paragraph::new(bar), f.area());
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod topbar_snapshots {
use super::*;
#[test]
fn library_active() {
let mut terminal = Terminal::new(TestBackend::new(100, 1)).unwrap();
terminal
.draw(|f| {
render_topbar(f, f.area(), Tab::Library, 0, 0, 0, false);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_badges() {
let mut terminal = Terminal::new(TestBackend::new(100, 1)).unwrap();
terminal
.draw(|f| {
render_topbar(f, f.area(), Tab::Queue, 5, 12, 3, false);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn notifications_open() {
let mut terminal = Terminal::new(TestBackend::new(100, 1)).unwrap();
terminal
.draw(|f| {
render_topbar(f, f.area(), Tab::Library, 0, 0, 5, true);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod library_snapshots {
use super::*;
#[test]
fn empty() {
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut state = LibraryState::new(vec![]);
terminal
.draw(|f| {
render_library(f, f.area(), &mut state);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_artists() {
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut state = LibraryState::new(test_artists());
terminal
.draw(|f| {
render_library(f, f.area(), &mut state);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn albums_focused() {
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut state = LibraryState::new(test_artists());
state.focus = LibraryFocus::Albums;
terminal
.draw(|f| {
render_library(f, f.area(), &mut state);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod help_modal_snapshots {
use super::*;
#[test]
fn normal_viewport() {
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal
.draw(|f| {
render_help_modal(f, f.area());
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn small_viewport() {
let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap();
terminal
.draw(|f| {
render_help_modal(f, f.area());
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod library_state {
use super::*;
#[test]
fn navigation() {
let mut state = LibraryState::new(test_artists());
assert_eq!(state.selected_artist_index(), Some(0));
assert_eq!(state.focus, LibraryFocus::Artists);
state.move_down();
assert_eq!(state.selected_artist_index(), Some(1));
state.focus_right();
assert_eq!(state.focus, LibraryFocus::Albums);
state.focus_left();
assert_eq!(state.focus, LibraryFocus::Artists);
}
#[test]
fn cycle_focus() {
let mut state = LibraryState::new(test_artists());
assert_eq!(state.focus, LibraryFocus::Artists);
state.cycle_focus();
assert_eq!(state.focus, LibraryFocus::Albums);
state.cycle_focus();
assert_eq!(state.focus, LibraryFocus::Tracks);
state.cycle_focus();
assert_eq!(state.focus, LibraryFocus::Artists);
}
}