use std::fmt; use musichoard::{Album, Artist, Collection, Format, IUrl, Track}; use ratatui::{ backend::Backend, layout::{Alignment, Rect}, style::{Color, Style}, widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph}, Frame, }; use super::{lib::IMusicHoard, Error}; #[derive(Debug)] pub enum UiError { Lib(String), } impl fmt::Display for UiError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { Self::Lib(ref s) => write!(f, "the musichoard library returned an error: {s}"), } } } impl From for UiError { fn from(err: musichoard::Error) -> UiError { UiError::Lib(err.to_string()) } } pub trait IUi { fn is_running(&self) -> bool; fn quit(&mut self); fn save(&mut self) -> Result<(), UiError>; fn increment_category(&mut self); fn decrement_category(&mut self); fn increment_selection(&mut self); fn decrement_selection(&mut self); fn toggle_overlay(&mut self); fn render(&mut self, frame: &mut Frame<'_, B>); } struct TrackSelection { state: ListState, } struct AlbumSelection { state: ListState, track: TrackSelection, } struct ArtistSelection { state: ListState, album: AlbumSelection, } impl TrackSelection { fn initialise(tracks: Option<&[Track]>) -> Self { let mut state = ListState::default(); if let Some(tracks) = tracks { state.select(if !tracks.is_empty() { Some(0) } else { None }); } else { state.select(None); }; TrackSelection { state } } fn increment(&mut self, tracks: &[Track]) { if let Some(index) = self.state.selected() { if let Some(result) = index.checked_add(1) { if result < tracks.len() { self.state.select(Some(result)); } } } } fn decrement(&mut self, _tracks: &[Track]) { if let Some(index) = self.state.selected() { if let Some(result) = index.checked_sub(1) { self.state.select(Some(result)); } } } } impl AlbumSelection { fn initialise(albums: Option<&[Album]>) -> Self { let mut state = ListState::default(); let track: TrackSelection; if let Some(albums) = albums { state.select(if !albums.is_empty() { Some(0) } else { None }); track = TrackSelection::initialise(albums.get(0).map(|a| a.tracks.as_slice())); } else { state.select(None); track = TrackSelection::initialise(None); } AlbumSelection { state, track } } fn increment(&mut self, albums: &[Album]) { if let Some(index) = self.state.selected() { if let Some(result) = index.checked_add(1) { if result < albums.len() { self.state.select(Some(result)); self.track = TrackSelection::initialise(Some(&albums[result].tracks)); } } } } fn increment_track(&mut self, albums: &[Album]) { if let Some(index) = self.state.selected() { self.track.increment(&albums[index].tracks); } } fn decrement(&mut self, albums: &[Album]) { if let Some(index) = self.state.selected() { if let Some(result) = index.checked_sub(1) { self.state.select(Some(result)); self.track = TrackSelection::initialise(Some(&albums[result].tracks)); } } } fn decrement_track(&mut self, albums: &[Album]) { if let Some(index) = self.state.selected() { self.track.decrement(&albums[index].tracks); } } } impl ArtistSelection { fn initialise(artists: Option<&[Artist]>) -> Self { let mut state = ListState::default(); let album: AlbumSelection; if let Some(artists) = artists { state.select(if !artists.is_empty() { Some(0) } else { None }); album = AlbumSelection::initialise(artists.get(0).map(|a| a.albums.as_slice())); } else { state.select(None); album = AlbumSelection::initialise(None); } ArtistSelection { state, album } } fn increment(&mut self, artists: &[Artist]) { if let Some(index) = self.state.selected() { if let Some(result) = index.checked_add(1) { if result < artists.len() { self.state.select(Some(result)); self.album = AlbumSelection::initialise(Some(&artists[result].albums)); } } } } fn increment_album(&mut self, artists: &[Artist]) { if let Some(index) = self.state.selected() { self.album.increment(&artists[index].albums); } } fn increment_track(&mut self, artists: &[Artist]) { if let Some(index) = self.state.selected() { self.album.increment_track(&artists[index].albums); } } fn decrement(&mut self, artists: &[Artist]) { if let Some(index) = self.state.selected() { if let Some(result) = index.checked_sub(1) { self.state.select(Some(result)); self.album = AlbumSelection::initialise(Some(&artists[result].albums)); } } } fn decrement_album(&mut self, artists: &[Artist]) { if let Some(index) = self.state.selected() { self.album.decrement(&artists[index].albums); } } fn decrement_track(&mut self, artists: &[Artist]) { if let Some(index) = self.state.selected() { self.album.decrement_track(&artists[index].albums); } } } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Category { Artist, Album, Track, } struct Selection { active: Category, artist: ArtistSelection, } impl Selection { fn new(artists: Option<&[Artist]>) -> Self { Selection { active: Category::Artist, artist: ArtistSelection::initialise(artists), } } fn increment_category(&mut self) { self.active = match self.active { Category::Artist => Category::Album, Category::Album => Category::Track, Category::Track => Category::Track, }; } fn decrement_category(&mut self) { self.active = match self.active { Category::Artist => Category::Artist, Category::Album => Category::Artist, Category::Track => Category::Album, }; } fn increment_selection(&mut self, collection: &Collection) { match self.active { Category::Artist => self.increment_artist(collection), Category::Album => self.increment_album(collection), Category::Track => self.increment_track(collection), } } fn decrement_selection(&mut self, collection: &Collection) { match self.active { Category::Artist => self.decrement_artist(collection), Category::Album => self.decrement_album(collection), Category::Track => self.decrement_track(collection), } } fn increment_artist(&mut self, artists: &[Artist]) { self.artist.increment(artists); } fn decrement_artist(&mut self, artists: &[Artist]) { self.artist.decrement(artists); } fn increment_album(&mut self, artists: &[Artist]) { self.artist.increment_album(artists); } fn decrement_album(&mut self, artists: &[Artist]) { self.artist.decrement_album(artists); } fn increment_track(&mut self, artists: &[Artist]) { self.artist.increment_track(artists); } fn decrement_track(&mut self, artists: &[Artist]) { self.artist.decrement_track(artists); } } pub struct Ui { music_hoard: MH, selection: Selection, overlay: bool, running: bool, } struct ArtistArea { list: Rect, } struct AlbumArea { list: Rect, info: Rect, } struct TrackArea { list: Rect, info: Rect, } struct FrameArea { artist: ArtistArea, album: AlbumArea, track: TrackArea, } impl FrameArea { fn new(frame: Rect) -> Self { let width_one_third = frame.width / 3; let height_one_third = frame.height / 3; let panel_width = width_one_third; let panel_width_last = frame.width - 2 * panel_width; let panel_height_top = frame.height - height_one_third; let panel_height_bottom = height_one_third; let artist_list = Rect { x: frame.x, y: frame.y, width: panel_width, height: frame.height, }; let album_list = Rect { x: artist_list.x + artist_list.width, y: frame.y, width: panel_width, height: panel_height_top, }; let album_info = Rect { x: album_list.x, y: album_list.y + album_list.height, width: album_list.width, height: panel_height_bottom, }; let track_list = Rect { x: album_list.x + album_list.width, y: frame.y, width: panel_width_last, height: panel_height_top, }; let track_info = Rect { x: track_list.x, y: track_list.y + track_list.height, width: track_list.width, height: panel_height_bottom, }; FrameArea { artist: ArtistArea { list: artist_list }, album: AlbumArea { list: album_list, info: album_info, }, track: TrackArea { list: track_list, info: track_info, }, } } } struct OverlayArea { artist: Rect, } impl OverlayArea { fn new(frame: Rect) -> Self { let margin_factor = 8; let width_margin = frame.width / margin_factor; let height_margin = frame.height / margin_factor; let artist = Rect { x: width_margin, y: height_margin, width: frame.width - (2 * width_margin), height: frame.height - (2 * height_margin), }; OverlayArea { artist } } } struct ArtistState<'a, 'b> { active: bool, list: List<'a>, state: &'b mut ListState, } impl<'a, 'b> ArtistState<'a, 'b> { fn new(active: bool, artists: &'a [Artist], state: &'b mut ListState) -> ArtistState<'a, 'b> { let list = List::new( artists .iter() .map(|a| ListItem::new(a.id.name.as_str())) .collect::>(), ); ArtistState { active, list, state, } } } struct ArtistOverlay<'a> { properties: Paragraph<'a>, } impl<'a> ArtistOverlay<'a> { fn opt_opt_to_str(opt: Option>) -> &str { opt.flatten().map(|item| item.url()).unwrap_or("") } fn opt_vec_to_string(opt_vec: Option<&Vec>, indent: &str) -> String { opt_vec .map(|vec| { if vec.len() < 2 { vec.get(0).map(|item| item.url()).unwrap_or("").to_string() } else { let indent = format!("\n{indent}"); let list = vec .iter() .map(|item| item.url()) .collect::>() .join(&indent); format!("{indent}{list}") } }) .unwrap_or_else(|| String::from("")) } fn new(artists: &'a [Artist], state: &ListState) -> ArtistOverlay<'a> { let artist = state.selected().map(|i| &artists[i]); let item_indent = " "; let list_indent = " - "; let properties = Paragraph::new(format!( "Artist: {}\n\n{item_indent}\ MusicBrainz: {}\n{item_indent}\ MusicButler: {}\n{item_indent}\ Bandcamp: {}\n{item_indent}\ Qobuz: {}", artist.map(|a| a.id.name.as_str()).unwrap_or(""), Self::opt_opt_to_str(artist.map(|a| a.properties.musicbrainz.as_ref())), Self::opt_vec_to_string(artist.map(|a| &a.properties.musicbutler), list_indent), Self::opt_vec_to_string(artist.map(|a| &a.properties.bandcamp), list_indent), Self::opt_opt_to_str(artist.map(|a| a.properties.qobuz.as_ref())), )); ArtistOverlay { properties } } } struct AlbumState<'a, 'b> { active: bool, list: List<'a>, state: &'b mut ListState, info: Paragraph<'a>, } impl<'a, 'b> AlbumState<'a, 'b> { fn new(active: bool, albums: &'a [Album], state: &'b mut ListState) -> AlbumState<'a, 'b> { let list = List::new( albums .iter() .map(|a| ListItem::new(a.id.title.as_str())) .collect::>(), ); let album = state.selected().map(|i| &albums[i]); let info = Paragraph::new(format!( "Title: {}\n\ Year: {}", album.map(|a| a.id.title.as_str()).unwrap_or(""), album .map(|a| a.id.year.to_string()) .unwrap_or_else(|| "".to_string()), )); AlbumState { active, list, state, info, } } } struct TrackState<'a, 'b> { active: bool, list: List<'a>, state: &'b mut ListState, info: Paragraph<'a>, } impl<'a, 'b> TrackState<'a, 'b> { fn new(active: bool, tracks: &'a [Track], state: &'b mut ListState) -> TrackState<'a, 'b> { let list = List::new( tracks .iter() .map(|tr| ListItem::new(tr.id.title.as_str())) .collect::>(), ); let track = state.selected().map(|i| &tracks[i]); let info = Paragraph::new(format!( "Track: {}\n\ Title: {}\n\ Artist: {}\n\ Quality: {}", track .map(|t| t.id.number.to_string()) .unwrap_or_else(|| "".to_string()), track.map(|t| t.id.title.as_str()).unwrap_or(""), track .map(|t| t.artist.join("; ")) .unwrap_or_else(|| "".to_string()), track .map(|t| match t.quality.format { Format::Flac => "FLAC".to_string(), Format::Mp3 => format!("MP3 {}kbps", t.quality.bitrate), }) .unwrap_or_else(|| "".to_string()), )); TrackState { active, list, state, info, } } } impl Ui { pub fn new(mut music_hoard: MH) -> Result { music_hoard.load_from_database()?; music_hoard.rescan_library()?; let selection = Selection::new(Some(music_hoard.get_collection())); Ok(Ui { music_hoard, selection, overlay: false, running: true, }) } fn style(_active: bool) -> Style { Style::default().fg(Color::White).bg(Color::Black) } fn block_style(active: bool) -> Style { Self::style(active) } fn highlight_style(active: bool) -> Style { if active { Style::default().fg(Color::White).bg(Color::DarkGray) } else { Self::style(false) } } fn block<'a>(title: &str, active: bool) -> Block<'a> { Block::default() .title_alignment(Alignment::Center) .borders(Borders::ALL) .border_type(BorderType::Rounded) .style(Self::block_style(active)) .title(format!(" {title} ")) } fn render_list_widget( title: &str, list: List, list_state: &mut ListState, active: bool, area: Rect, frame: &mut Frame<'_, B>, ) { frame.render_stateful_widget( list.highlight_style(Self::highlight_style(active)) .highlight_symbol(">> ") .style(Self::style(active)) .block(Self::block(title, active)), area, list_state, ); } fn render_info_widget( title: &str, paragraph: Paragraph, active: bool, area: Rect, frame: &mut Frame<'_, B>, ) { frame.render_widget( paragraph .style(Self::style(active)) .block(Self::block(title, active)), area, ); } fn render_overlay_widget( title: &str, paragraph: Paragraph, area: Rect, frame: &mut Frame<'_, B>, ) { frame.render_widget(Clear, area); frame.render_widget( paragraph .style(Self::style(true)) .block(Self::block(title, true)), area, ); } fn render_artist_column(st: ArtistState, ar: ArtistArea, fr: &mut Frame<'_, B>) { Self::render_list_widget("Artists", st.list, st.state, st.active, ar.list, fr); } fn render_album_column(st: AlbumState, ar: AlbumArea, fr: &mut Frame<'_, B>) { Self::render_list_widget("Albums", st.list, st.state, st.active, ar.list, fr); Self::render_info_widget("Album info", st.info, st.active, ar.info, fr); } fn render_track_column(st: TrackState, ar: TrackArea, fr: &mut Frame<'_, B>) { Self::render_list_widget("Tracks", st.list, st.state, st.active, ar.list, fr); Self::render_info_widget("Track info", st.info, st.active, ar.info, fr); } fn render_collection(&mut self, frame: &mut Frame<'_, B>) { let active = self.selection.active; let areas = FrameArea::new(frame.size()); let artists = self.music_hoard.get_collection(); let artist_selection = &mut self.selection.artist; let artist_state = ArtistState::new( active == Category::Artist, artists, &mut artist_selection.state, ); Self::render_artist_column(artist_state, areas.artist, frame); let no_albums: Vec = vec![]; let albums = artist_selection .state .selected() .map(|i| &artists[i].albums) .unwrap_or_else(|| &no_albums); let album_selection = &mut artist_selection.album; let album_state = AlbumState::new( active == Category::Album, albums, &mut album_selection.state, ); Self::render_album_column(album_state, areas.album, frame); let no_tracks: Vec = vec![]; let tracks = album_selection .state .selected() .map(|i| &albums[i].tracks) .unwrap_or_else(|| &no_tracks); let track_selection = &mut album_selection.track; let track_state = TrackState::new( active == Category::Track, tracks, &mut track_selection.state, ); Self::render_track_column(track_state, areas.track, frame); } fn render_overlay(&mut self, frame: &mut Frame<'_, B>) { let areas = OverlayArea::new(frame.size()); let artists = self.music_hoard.get_collection(); let artist_selection = &mut self.selection.artist; let artist_overlay = ArtistOverlay::new(artists, &artist_selection.state); Self::render_overlay_widget("Artist", artist_overlay.properties, areas.artist, frame); } } impl IUi for Ui { fn is_running(&self) -> bool { self.running } fn quit(&mut self) { self.running = false; } fn save(&mut self) -> Result<(), UiError> { self.music_hoard.save_to_database()?; Ok(()) } fn increment_category(&mut self) { self.selection.increment_category(); } fn decrement_category(&mut self) { self.selection.decrement_category(); } fn increment_selection(&mut self) { self.selection .increment_selection(self.music_hoard.get_collection()); } fn decrement_selection(&mut self) { self.selection .decrement_selection(self.music_hoard.get_collection()); } fn toggle_overlay(&mut self) { self.overlay = !self.overlay; } fn render(&mut self, frame: &mut Frame<'_, B>) { self.render_collection(frame); if self.overlay { self.render_overlay(frame); } } } #[cfg(test)] mod tests { use crate::tests::COLLECTION; use crate::tui::lib::MockIMusicHoard; use crate::tui::tests::{terminal, ui}; use super::*; pub fn music_hoard(collection: Collection) -> MockIMusicHoard { let mut music_hoard = MockIMusicHoard::new(); music_hoard .expect_load_from_database() .times(1) .return_once(|| Ok(())); music_hoard .expect_rescan_library() .times(1) .return_once(|| Ok(())); music_hoard.expect_get_collection().return_const(collection); music_hoard } #[test] fn test_track_selection() { let tracks = &COLLECTION[0].albums[0].tracks; assert!(tracks.len() > 1); let empty = TrackSelection::initialise(None); assert_eq!(empty.state.selected(), None); let empty = TrackSelection::initialise(Some(&vec![])); assert_eq!(empty.state.selected(), None); let mut sel = TrackSelection::initialise(Some(tracks)); assert_eq!(sel.state.selected(), Some(0)); sel.decrement(tracks); assert_eq!(sel.state.selected(), Some(0)); sel.increment(tracks); assert_eq!(sel.state.selected(), Some(1)); sel.decrement(tracks); assert_eq!(sel.state.selected(), Some(0)); for _ in 0..(tracks.len() + 5) { sel.increment(tracks); } assert_eq!(sel.state.selected(), Some(tracks.len() - 1)); // Artifical test case to verify upper limit. sel.state.select(Some(std::usize::MAX)); assert_eq!(sel.state.selected(), Some(std::usize::MAX)); sel.increment(&vec![]); assert_eq!(sel.state.selected(), Some(std::usize::MAX)); } #[test] fn test_album_selection() { let albums = &COLLECTION[0].albums; assert!(albums.len() > 1); let empty = AlbumSelection::initialise(None); assert_eq!(empty.state.selected(), None); let empty = AlbumSelection::initialise(Some(&vec![])); assert_eq!(empty.state.selected(), None); let mut sel = AlbumSelection::initialise(Some(albums)); assert_eq!(sel.state.selected(), Some(0)); assert_eq!(sel.track.state.selected(), Some(0)); sel.increment_track(albums); assert_eq!(sel.state.selected(), Some(0)); assert_eq!(sel.track.state.selected(), Some(1)); // Verify that decrement that doesn't change index does not reset track. sel.decrement(albums); assert_eq!(sel.state.selected(), Some(0)); assert_eq!(sel.track.state.selected(), Some(1)); sel.increment(albums); assert_eq!(sel.state.selected(), Some(1)); assert_eq!(sel.track.state.selected(), Some(0)); sel.decrement(albums); assert_eq!(sel.state.selected(), Some(0)); assert_eq!(sel.track.state.selected(), Some(0)); for _ in 0..(albums.len() + 5) { sel.increment(albums); } assert_eq!(sel.state.selected(), Some(albums.len() - 1)); assert_eq!(sel.track.state.selected(), Some(0)); sel.increment_track(albums); assert_eq!(sel.state.selected(), Some(albums.len() - 1)); assert_eq!(sel.track.state.selected(), Some(1)); // Verify that increment that doesn't change index does not reset track. sel.increment(albums); assert_eq!(sel.state.selected(), Some(albums.len() - 1)); assert_eq!(sel.track.state.selected(), Some(1)); // Artifical test case to verify upper limit. sel.state.select(Some(std::usize::MAX)); sel.track.state.select(Some(1)); assert_eq!(sel.state.selected(), Some(std::usize::MAX)); assert_eq!(sel.track.state.selected(), Some(1)); sel.increment(&vec![]); assert_eq!(sel.state.selected(), Some(std::usize::MAX)); assert_eq!(sel.track.state.selected(), Some(1)); } #[test] fn test_artist_selection() { let artists = &COLLECTION; assert!(artists.len() > 1); let empty = ArtistSelection::initialise(None); assert_eq!(empty.state.selected(), None); let empty = ArtistSelection::initialise(Some(&vec![])); assert_eq!(empty.state.selected(), None); let mut sel = ArtistSelection::initialise(Some(artists)); assert_eq!(sel.state.selected(), Some(0)); assert_eq!(sel.album.state.selected(), Some(0)); sel.increment_album(artists); assert_eq!(sel.state.selected(), Some(0)); assert_eq!(sel.album.state.selected(), Some(1)); // Verify that decrement that doesn't change index does not reset album. sel.decrement(artists); assert_eq!(sel.state.selected(), Some(0)); assert_eq!(sel.album.state.selected(), Some(1)); sel.increment(artists); assert_eq!(sel.state.selected(), Some(1)); assert_eq!(sel.album.state.selected(), Some(0)); sel.decrement(artists); assert_eq!(sel.state.selected(), Some(0)); assert_eq!(sel.album.state.selected(), Some(0)); for _ in 0..(artists.len() + 5) { sel.increment(artists); } assert_eq!(sel.state.selected(), Some(artists.len() - 1)); assert_eq!(sel.album.state.selected(), Some(0)); sel.increment_album(artists); assert_eq!(sel.state.selected(), Some(artists.len() - 1)); assert_eq!(sel.album.state.selected(), Some(1)); // Verify that increment that doesn't change index does not reset album. sel.increment(artists); assert_eq!(sel.state.selected(), Some(artists.len() - 1)); assert_eq!(sel.album.state.selected(), Some(1)); // Artifical test case to verify upper limit. sel.state.select(Some(std::usize::MAX)); sel.album.state.select(Some(1)); assert_eq!(sel.state.selected(), Some(std::usize::MAX)); assert_eq!(sel.album.state.selected(), Some(1)); sel.increment(&vec![]); assert_eq!(sel.state.selected(), Some(std::usize::MAX)); assert_eq!(sel.album.state.selected(), Some(1)); } #[test] fn ui_running() { let mut ui = Ui::new(music_hoard(COLLECTION.to_owned())).unwrap(); assert!(ui.is_running()); ui.quit(); assert!(!ui.is_running()); } #[test] fn ui_save() { let mut music_hoard = music_hoard(COLLECTION.to_owned()); music_hoard .expect_save_to_database() .times(1) .return_once(|| Ok(())); let mut ui = Ui::new(music_hoard).unwrap(); let result = ui.save(); assert!(result.is_ok()); } #[test] fn ui_modifiers() { let mut ui = Ui::new(music_hoard(COLLECTION.to_owned())).unwrap(); assert!(ui.is_running()); assert_eq!(ui.selection.active, Category::Artist); assert_eq!(ui.selection.artist.state.selected(), Some(0)); assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); ui.increment_selection(); assert_eq!(ui.selection.active, Category::Artist); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); ui.increment_category(); assert_eq!(ui.selection.active, Category::Album); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); ui.increment_selection(); assert_eq!(ui.selection.active, Category::Album); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); ui.increment_category(); assert_eq!(ui.selection.active, Category::Track); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); ui.increment_selection(); assert_eq!(ui.selection.active, Category::Track); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(1)); ui.increment_category(); assert_eq!(ui.selection.active, Category::Track); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(1)); ui.decrement_selection(); assert_eq!(ui.selection.active, Category::Track); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); ui.increment_selection(); ui.decrement_category(); assert_eq!(ui.selection.active, Category::Album); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(1)); ui.decrement_selection(); assert_eq!(ui.selection.active, Category::Album); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); ui.increment_selection(); ui.decrement_category(); assert_eq!(ui.selection.active, Category::Artist); assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); ui.decrement_selection(); assert_eq!(ui.selection.active, Category::Artist); assert_eq!(ui.selection.artist.state.selected(), Some(0)); assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); ui.increment_category(); ui.increment_selection(); ui.decrement_category(); ui.decrement_selection(); ui.decrement_category(); assert_eq!(ui.selection.active, Category::Artist); assert_eq!(ui.selection.artist.state.selected(), Some(0)); assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); } #[test] fn app_no_tracks() { let mut collection = COLLECTION.to_owned(); collection[0].albums[0].tracks = vec![]; let mut app = Ui::new(music_hoard(collection)).unwrap(); assert!(app.is_running()); assert_eq!(app.selection.active, Category::Artist); assert_eq!(app.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.increment_category(); app.increment_category(); app.increment_selection(); assert_eq!(app.selection.active, Category::Track); assert_eq!(app.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.decrement_selection(); assert_eq!(app.selection.active, Category::Track); assert_eq!(app.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), None); } #[test] fn app_no_albums() { let mut collection = COLLECTION.to_owned(); collection[0].albums = vec![]; let mut app = Ui::new(music_hoard(collection)).unwrap(); assert!(app.is_running()); assert_eq!(app.selection.active, Category::Artist); assert_eq!(app.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.increment_category(); app.increment_selection(); assert_eq!(app.selection.active, Category::Album); assert_eq!(app.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.decrement_selection(); assert_eq!(app.selection.active, Category::Album); assert_eq!(app.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.increment_category(); app.increment_selection(); assert_eq!(app.selection.active, Category::Track); assert_eq!(app.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.decrement_selection(); assert_eq!(app.selection.active, Category::Track); assert_eq!(app.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); } #[test] fn app_no_artists() { let mut app = Ui::new(music_hoard(vec![])).unwrap(); assert!(app.is_running()); assert_eq!(app.selection.active, Category::Artist); assert_eq!(app.selection.artist.state.selected(), None); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.increment_selection(); assert_eq!(app.selection.active, Category::Artist); assert_eq!(app.selection.artist.state.selected(), None); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.decrement_selection(); assert_eq!(app.selection.active, Category::Artist); assert_eq!(app.selection.artist.state.selected(), None); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.increment_category(); app.increment_selection(); assert_eq!(app.selection.active, Category::Album); assert_eq!(app.selection.artist.state.selected(), None); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.decrement_selection(); assert_eq!(app.selection.active, Category::Album); assert_eq!(app.selection.artist.state.selected(), None); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.increment_category(); app.increment_selection(); assert_eq!(app.selection.active, Category::Track); assert_eq!(app.selection.artist.state.selected(), None); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); app.decrement_selection(); assert_eq!(app.selection.active, Category::Track); assert_eq!(app.selection.artist.state.selected(), None); assert_eq!(app.selection.artist.album.state.selected(), None); assert_eq!(app.selection.artist.album.track.state.selected(), None); } // This is UI so the only sensible unit test is to run the code through various app states. #[test] fn empty() { let mut terminal = terminal(); let mut ui = ui(vec![]); terminal.draw(|frame| ui.render(frame)).unwrap(); } #[test] fn collection() { let mut terminal = terminal(); let mut ui = ui(COLLECTION.to_owned()); terminal.draw(|frame| ui.render(frame)).unwrap(); // Change the track (which has a different track format). ui.increment_category(); ui.increment_category(); ui.increment_selection(); terminal.draw(|frame| ui.render(frame)).unwrap(); } #[test] fn overlay() { let mut terminal = terminal(); let mut ui = ui(COLLECTION.to_owned()); terminal.draw(|frame| ui.render(frame)).unwrap(); ui.toggle_overlay(); terminal.draw(|frame| ui.render(frame)).unwrap(); // Change the artist (which has a multi-link entry). ui.increment_selection(); terminal.draw(|frame| ui.render(frame)).unwrap(); ui.toggle_overlay(); terminal.draw(|frame| ui.render(frame)).unwrap(); } #[test] fn errors() { let ui_err: UiError = musichoard::Error::DatabaseError(String::from("get rekt")).into(); assert!(!ui_err.to_string().is_empty()); assert!(!format!("{:?}", ui_err).is_empty()); } }