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:
Generated
+36
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
+37
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
+39
-28
@@ -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()
|
||||
@@ -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
@@ -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);
|
||||
|
||||
+8
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<u8>() {
|
||||
let month_match = e.date.contains(&format!("{:04}-{:02}", year, month));
|
||||
return month_match && day == d;
|
||||
}
|
||||
&& let Ok(day) = day_str.parse::<u8>()
|
||||
{
|
||||
let month_match = e.date.contains(&format!("{:04}-{:02}", year, month));
|
||||
return month_match && day == d;
|
||||
}
|
||||
false
|
||||
})
|
||||
.cloned()
|
||||
|
||||
@@ -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) "
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user