diff --git a/Cargo.lock b/Cargo.lock index 3985c88..4289692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 24759d6..5046b86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/build.rs b/build.rs index ae4f81f..37d1e0e 100644 --- a/build.rs +++ b/build.rs @@ -1,17 +1,22 @@ fn main() -> Result<(), Box> { 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(()) } diff --git a/docs/testing-possible-solutions.md b/docs/testing-possible-solutions.md new file mode 100644 index 0000000..75ef00d --- /dev/null +++ b/docs/testing-possible-solutions.md @@ -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/ diff --git a/src/app.rs b/src/app.rs index 7de3384..944925f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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) = ¬if.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,18 +521,20 @@ impl App { if rel_y < tracks_start_row { if let Some(artist) = self.library.selected_artist() - && 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; - } + && 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; + } } else { let track_row = rel_y - tracks_start_row; if let Some(album) = self.library.selected_album() - && track_row < album.total as usize { - self.library.track_state.select(Some(track_row)); - self.library.focus = LibraryFocus::Tracks; - } + && track_row < album.total as usize + { + self.library.track_state.select(Some(track_row)); + self.library.focus = LibraryFocus::Tracks; + } } } } diff --git a/src/grpc/mod.rs b/src/grpc/mod.rs index 9797084..5a8a2d1 100644 --- a/src/grpc/mod.rs +++ b/src/grpc/mod.rs @@ -17,7 +17,10 @@ pub enum GrpcRequest { #[allow(dead_code, clippy::large_enum_variant)] pub enum GrpcResponse { Artists(Vec), - Album { album: AlbumDetail, tracks: Vec }, + Album { + album: AlbumDetail, + tracks: Vec, + }, Error(String), } @@ -46,14 +49,11 @@ impl GrpcClient { &mut self, album_id: String, ) -> Result<(AlbumDetail, Vec), 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() { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b363777 --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index b62c052..82d1f4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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); diff --git a/src/ui/library.rs b/src/ui/library.rs index a9542bd..a7ded71 100644 --- a/src/ui/library.rs +++ b/src/ui/library.rs @@ -75,23 +75,26 @@ impl LibraryState { match self.focus { LibraryFocus::Artists => { if let Some(i) = self.artist_state.selected() - && i > 0 { - self.artist_state.select(Some(i - 1)); - self.reset_album_selection(); - } + && 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 { - self.album_state.select(Some(i - 1)); - self.reset_track_selection(); - } + && 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 { - self.track_state.select(Some(i - 1)); - } + && i > 0 + { + self.track_state.select(Some(i - 1)); + } } } } @@ -101,10 +104,11 @@ impl LibraryState { LibraryFocus::Artists => { let max = self.artists.len().saturating_sub(1); if let Some(i) = self.artist_state.selected() - && i < max { - self.artist_state.select(Some(i + 1)); - self.reset_album_selection(); - } + && i < max + { + self.artist_state.select(Some(i + 1)); + self.reset_album_selection(); + } } LibraryFocus::Albums => { let max = self @@ -112,17 +116,19 @@ impl LibraryState { .map(|a| a.albums.len().saturating_sub(1)) .unwrap_or(0); if let Some(i) = self.album_state.selected() - && i < max { - self.album_state.select(Some(i + 1)); - self.reset_track_selection(); - } + && i < max + { + self.album_state.select(Some(i + 1)); + self.reset_track_selection(); + } } LibraryFocus::Tracks => { let max = self.track_count().saturating_sub(1); if let Some(i) = self.track_state.selected() - && i < max { - self.track_state.select(Some(i + 1)); - } + && i < max + { + self.track_state.select(Some(i + 1)); + } } } } @@ -386,11 +392,12 @@ 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 albums = artist.albums.clone(); - let focus = state.focus; - render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state); - } + && 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); + } let album_title = state .selected_album() @@ -597,7 +604,7 @@ impl LibraryState { pub fn cache_tracks(&mut self, album_id: String, tracks: Vec) { self.tracks_cache.insert(album_id.clone(), tracks); - + if self.pending_album_id.as_ref() == Some(&album_id) { self.pending_album_id = None; self.load_tracks_from_cache(&album_id); @@ -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) + }); } } @@ -617,22 +628,22 @@ impl LibraryState { pub fn needs_fetch(&mut self) -> Option { let current_album_id = self.selected_album().map(|a| a.id.clone())?; - + if current_album_id.is_empty() { return None; } - + if self.tracks_cache.contains_key(¤t_album_id) { if self.pending_album_id.as_ref() != Some(¤t_album_id) { self.load_tracks_from_cache(¤t_album_id); } return None; } - + if self.pending_album_id.as_ref() == Some(¤t_album_id) { return None; } - + self.pending_album_id = Some(current_album_id.clone()); self.tracks.clear(); Some(current_album_id) diff --git a/src/ui/notifications.rs b/src/ui/notifications.rs index 1cf68c5..fca0f91 100644 --- a/src/ui/notifications.rs +++ b/src/ui/notifications.rs @@ -91,12 +91,12 @@ impl NotificationManager { created_at: Instant::now(), }; self.next_id += 1; - + self.history.push(notification.clone()); if self.history.len() > MAX_HISTORY { self.history.remove(0); } - + self.active.push(notification); } @@ -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); diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 9e00bbc..ee83951 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -80,15 +80,16 @@ impl Widget for Pane<'_> { block.render(area, buf); if let Some(footer) = self.footer - && 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); + && 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); - if footer_width > 0 { - buf.set_line(footer_x, footer_y, &footer, footer_width); - } + if footer_width > 0 { + buf.set_line(footer_x, footer_y, &footer, footer_width); } + } } } diff --git a/src/ui/progress_bar.rs b/src/ui/progress_bar.rs index b8a5891..27864cf 100644 --- a/src/ui/progress_bar.rs +++ b/src/ui/progress_bar.rs @@ -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); - } -} diff --git a/src/ui/views/calendar.rs b/src/ui/views/calendar.rs index dded1ba..0ae8f0c 100644 --- a/src/ui/views/calendar.rs +++ b/src/ui/views/calendar.rs @@ -71,10 +71,11 @@ 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::() { - let month_match = e.date.contains(&format!("{:04}-{:02}", year, month)); - return month_match && day == d; - } + && let Ok(day) = day_str.parse::() + { + let month_match = e.date.contains(&format!("{:04}-{:02}", year, month)); + return month_match && day == d; + } false }) .cloned() diff --git a/tests/snapshots/ui_snapshots__help_modal_snapshots__normal_viewport.snap b/tests/snapshots/ui_snapshots__help_modal_snapshots__normal_viewport.snap new file mode 100644 index 0000000..c7eaf77 --- /dev/null +++ b/tests/snapshots/ui_snapshots__help_modal_snapshots__normal_viewport.snap @@ -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} 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) │ " +" └──────────────────────────────────────────────────────────────────────────────────────────────┘ " +" " diff --git a/tests/snapshots/ui_snapshots__help_modal_snapshots__small_viewport.snap b/tests/snapshots/ui_snapshots__help_modal_snapshots__small_viewport.snap new file mode 100644 index 0000000..9934e65 --- /dev/null +++ b/tests/snapshots/ui_snapshots__help_modal_snapshots__small_viewport.snap @@ -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} 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│ " +" └──────────────────────────────────────────────────────┘ " +" " diff --git a/tests/snapshots/ui_snapshots__library_snapshots__albums_focused.snap b/tests/snapshots/ui_snapshots__library_snapshots__albums_focused.snap new file mode 100644 index 0000000..8540a06 --- /dev/null +++ b/tests/snapshots/ui_snapshots__library_snapshots__albums_focused.snap @@ -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) │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"└──────────────────────────────┘└──────────────────────────────────────────────────────────────────┘" diff --git a/tests/snapshots/ui_snapshots__library_snapshots__empty.snap b/tests/snapshots/ui_snapshots__library_snapshots__empty.snap new file mode 100644 index 0000000..26eeb5b --- /dev/null +++ b/tests/snapshots/ui_snapshots__library_snapshots__empty.snap @@ -0,0 +1,34 @@ +--- +source: tests/ui_snapshots.rs +expression: terminal.backend() +--- +"┌─[ Artists · 0 ]──────────────┐┌─[ Detail · ]────────────────────────────────────────────────────┐" +"│ ││No artist selected │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"└──────────────────────────────┘└──────────────────────────────────────────────────────────────────┘" diff --git a/tests/snapshots/ui_snapshots__library_snapshots__with_artists.snap b/tests/snapshots/ui_snapshots__library_snapshots__with_artists.snap new file mode 100644 index 0000000..8540a06 --- /dev/null +++ b/tests/snapshots/ui_snapshots__library_snapshots__with_artists.snap @@ -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) │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"└──────────────────────────────┘└──────────────────────────────────────────────────────────────────┘" diff --git a/tests/snapshots/ui_snapshots__progress_bar_snapshots__complete.snap b/tests/snapshots/ui_snapshots__progress_bar_snapshots__complete.snap new file mode 100644 index 0000000..bf5643d --- /dev/null +++ b/tests/snapshots/ui_snapshots__progress_bar_snapshots__complete.snap @@ -0,0 +1,5 @@ +--- +source: tests/ui_snapshots.rs +expression: terminal.backend() +--- +"▰▰▰▰▰▰▰▰▰▰ " diff --git a/tests/snapshots/ui_snapshots__progress_bar_snapshots__empty.snap b/tests/snapshots/ui_snapshots__progress_bar_snapshots__empty.snap new file mode 100644 index 0000000..c3e724f --- /dev/null +++ b/tests/snapshots/ui_snapshots__progress_bar_snapshots__empty.snap @@ -0,0 +1,5 @@ +--- +source: tests/ui_snapshots.rs +expression: terminal.backend() +--- +"▱▱▱▱▱▱▱▱▱▱ " diff --git a/tests/snapshots/ui_snapshots__progress_bar_snapshots__partial.snap b/tests/snapshots/ui_snapshots__progress_bar_snapshots__partial.snap new file mode 100644 index 0000000..e3074ae --- /dev/null +++ b/tests/snapshots/ui_snapshots__progress_bar_snapshots__partial.snap @@ -0,0 +1,5 @@ +--- +source: tests/ui_snapshots.rs +expression: terminal.backend() +--- +"▰▰▰▰▰▱▱▱▱▱ " diff --git a/tests/snapshots/ui_snapshots__topbar_snapshots__library_active.snap b/tests/snapshots/ui_snapshots__topbar_snapshots__library_active.snap new file mode 100644 index 0000000..c2919f0 --- /dev/null +++ b/tests/snapshots/ui_snapshots__topbar_snapshots__library_active.snap @@ -0,0 +1,5 @@ +--- +source: tests/ui_snapshots.rs +expression: terminal.backend() +--- +" ▲ harmony Library Wanted Queue History Calendar Settings ● Notifications " diff --git a/tests/snapshots/ui_snapshots__topbar_snapshots__notifications_open.snap b/tests/snapshots/ui_snapshots__topbar_snapshots__notifications_open.snap new file mode 100644 index 0000000..a584d40 --- /dev/null +++ b/tests/snapshots/ui_snapshots__topbar_snapshots__notifications_open.snap @@ -0,0 +1,5 @@ +--- +source: tests/ui_snapshots.rs +expression: terminal.backend() +--- +" ▲ harmony Library Wanted Queue History Calendar Settings ● Notifications (5) " diff --git a/tests/snapshots/ui_snapshots__topbar_snapshots__with_badges.snap b/tests/snapshots/ui_snapshots__topbar_snapshots__with_badges.snap new file mode 100644 index 0000000..e3d3c4e --- /dev/null +++ b/tests/snapshots/ui_snapshots__topbar_snapshots__with_badges.snap @@ -0,0 +1,5 @@ +--- +source: tests/ui_snapshots.rs +expression: terminal.backend() +--- +" ▲ harmony Library Wanted 12 Queue 5 History Calendar Settings ● Notifications (3) " diff --git a/tests/ui_snapshots.rs b/tests/ui_snapshots.rs new file mode 100644 index 0000000..f1dfd60 --- /dev/null +++ b/tests/ui_snapshots.rs @@ -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 { + 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); + } +}