mecomp_tui/ui/components/content_view/views/
artist.rs

1//! Views for both a single artist, and the library of artists.
2
3use std::sync::Mutex;
4
5use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
6use mecomp_storage::db::schemas::artist::Artist;
7use ratatui::{
8    layout::{Margin, Rect},
9    style::{Style, Stylize},
10    text::{Line, Span},
11    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation},
12};
13use tokio::sync::mpsc::UnboundedSender;
14
15use crate::{
16    state::action::{Action, AudioAction, PopupAction, QueueAction, ViewAction},
17    ui::{
18        colors::{BORDER_FOCUSED, BORDER_UNFOCUSED, TEXT_HIGHLIGHT},
19        components::{content_view::ActiveView, Component, ComponentRender, RenderProps},
20        widgets::{
21            popups::PopupType,
22            tree::{state::CheckTreeState, CheckTree},
23        },
24        AppState,
25    },
26};
27
28use super::{
29    checktree_utils::create_artist_tree_leaf, generic::ItemView, sort_mode::NameSort,
30    traits::SortMode, ArtistViewProps, RADIO_SIZE,
31};
32
33#[allow(clippy::module_name_repetitions)]
34pub type ArtistView = ItemView<ArtistViewProps>;
35
36pub struct LibraryArtistsView {
37    /// Action Sender
38    pub action_tx: UnboundedSender<Action>,
39    /// Mapped Props from state
40    props: Props,
41    /// tree state
42    tree_state: Mutex<CheckTreeState<String>>,
43}
44
45struct Props {
46    artists: Box<[Artist]>,
47    sort_mode: NameSort<Artist>,
48}
49impl Component for LibraryArtistsView {
50    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
51    where
52        Self: Sized,
53    {
54        let sort_mode = NameSort::default();
55        let mut artists = state.library.artists.clone();
56        sort_mode.sort_items(&mut artists);
57        Self {
58            action_tx,
59            props: Props { artists, sort_mode },
60            tree_state: Mutex::new(CheckTreeState::default()),
61        }
62    }
63
64    fn move_with_state(self, state: &AppState) -> Self
65    where
66        Self: Sized,
67    {
68        let mut artists = state.library.artists.clone();
69        self.props.sort_mode.sort_items(&mut artists);
70        let tree_state = if state.active_view == ActiveView::Artists {
71            self.tree_state
72        } else {
73            Mutex::new(CheckTreeState::default())
74        };
75
76        Self {
77            props: Props {
78                artists,
79                ..self.props
80            },
81            tree_state,
82            ..self
83        }
84    }
85
86    fn name(&self) -> &'static str {
87        "Library Artists View"
88    }
89
90    fn handle_key_event(&mut self, key: KeyEvent) {
91        match key.code {
92            // arrow keys
93            KeyCode::PageUp => {
94                self.tree_state.lock().unwrap().select_relative(|current| {
95                    current.map_or(self.props.artists.len() - 1, |c| c.saturating_sub(10))
96                });
97            }
98            KeyCode::Up => {
99                self.tree_state.lock().unwrap().key_up();
100            }
101            KeyCode::PageDown => {
102                self.tree_state
103                    .lock()
104                    .unwrap()
105                    .select_relative(|current| current.map_or(0, |c| c.saturating_add(10)));
106            }
107            KeyCode::Down => {
108                self.tree_state.lock().unwrap().key_down();
109            }
110            KeyCode::Left => {
111                self.tree_state.lock().unwrap().key_left();
112            }
113            KeyCode::Right => {
114                self.tree_state.lock().unwrap().key_right();
115            }
116            KeyCode::Char(' ') => {
117                self.tree_state.lock().unwrap().key_space();
118            }
119            // Enter key opens selected view
120            KeyCode::Enter => {
121                if self.tree_state.lock().unwrap().toggle_selected() {
122                    let things = self.tree_state.lock().unwrap().get_selected_thing();
123
124                    if let Some(thing) = things {
125                        self.action_tx
126                            .send(Action::ActiveView(ViewAction::Set(thing.into())))
127                            .unwrap();
128                    }
129                }
130            }
131            // when there are checked items, "q" will send the checked items to the queue
132            KeyCode::Char('q') => {
133                let things = self.tree_state.lock().unwrap().get_checked_things();
134                if !things.is_empty() {
135                    self.action_tx
136                        .send(Action::Audio(AudioAction::Queue(QueueAction::Add(things))))
137                        .unwrap();
138                }
139            }
140            // when there are checked items, "r" will start a radio with the checked items
141            KeyCode::Char('r') => {
142                let things = self.tree_state.lock().unwrap().get_checked_things();
143                if !things.is_empty() {
144                    self.action_tx
145                        .send(Action::ActiveView(ViewAction::Set(ActiveView::Radio(
146                            things, RADIO_SIZE,
147                        ))))
148                        .unwrap();
149                }
150            }
151            // when there are checked items, "p" will send the checked items to the playlist
152            KeyCode::Char('p') => {
153                let things = self.tree_state.lock().unwrap().get_checked_things();
154                if !things.is_empty() {
155                    self.action_tx
156                        .send(Action::Popup(PopupAction::Open(PopupType::Playlist(
157                            things,
158                        ))))
159                        .unwrap();
160                }
161            }
162            // Change sort mode
163            KeyCode::Char('s') => {
164                self.props.sort_mode = self.props.sort_mode.next();
165                self.props.sort_mode.sort_items(&mut self.props.artists);
166            }
167            KeyCode::Char('S') => {
168                self.props.sort_mode = self.props.sort_mode.prev();
169                self.props.sort_mode.sort_items(&mut self.props.artists);
170            }
171            _ => {}
172        }
173    }
174
175    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
176        // adjust the area to account for the border
177        let area = area.inner(Margin::new(1, 2));
178
179        let result = self
180            .tree_state
181            .lock()
182            .unwrap()
183            .handle_mouse_event(mouse, area);
184        if let Some(action) = result {
185            self.action_tx.send(action).unwrap();
186        }
187    }
188}
189
190impl ComponentRender<RenderProps> for LibraryArtistsView {
191    fn render_border(&self, frame: &mut ratatui::Frame, props: RenderProps) -> RenderProps {
192        let border_style = if props.is_focused {
193            Style::default().fg(BORDER_FOCUSED.into())
194        } else {
195            Style::default().fg(BORDER_UNFOCUSED.into())
196        };
197
198        // draw primary border
199        let border = Block::bordered()
200            .title_top(Line::from(vec![
201                Span::styled("Library Artists".to_string(), Style::default().bold()),
202                Span::raw(" sorted by: "),
203                Span::styled(self.props.sort_mode.to_string(), Style::default().italic()),
204            ]))
205            .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
206            .border_style(border_style);
207        let content_area = border.inner(props.area);
208        frame.render_widget(border, props.area);
209
210        // draw an additional border around the content area to display additional instructions
211        let border = Block::default()
212            .borders(Borders::TOP | Borders::BOTTOM)
213            .title_top(
214                if self
215                    .tree_state
216                    .lock()
217                    .unwrap()
218                    .get_checked_things()
219                    .is_empty()
220                {
221                    ""
222                } else {
223                    "q: add to queue | r: start radio | p: add to playlist "
224                },
225            )
226            .title_bottom("s/S: change sort")
227            .border_style(border_style);
228        let area = border.inner(content_area);
229        frame.render_widget(border, content_area);
230
231        RenderProps { area, ..props }
232    }
233
234    fn render_content(&self, frame: &mut ratatui::Frame, props: RenderProps) {
235        // create a tree for the artists
236        let items = self
237            .props
238            .artists
239            .iter()
240            .map(create_artist_tree_leaf)
241            .collect::<Vec<_>>();
242
243        // render the artists
244        frame.render_stateful_widget(
245            CheckTree::new(&items)
246                .unwrap()
247                .highlight_style(Style::default().fg(TEXT_HIGHLIGHT.into()).bold())
248                .experimental_scrollbar(Some(Scrollbar::new(ScrollbarOrientation::VerticalRight))),
249            props.area,
250            &mut self.tree_state.lock().unwrap(),
251        );
252    }
253}
254
255#[cfg(test)]
256mod sort_mode_tests {
257    use super::*;
258    use pretty_assertions::assert_eq;
259    use rstest::rstest;
260    use std::time::Duration;
261
262    #[rstest]
263    #[case(NameSort::default(), NameSort::default())]
264    fn test_sort_mode_next_prev(
265        #[case] mode: NameSort<Artist>,
266        #[case] expected: NameSort<Artist>,
267    ) {
268        assert_eq!(mode.next(), expected);
269        assert_eq!(mode.next().prev(), mode);
270    }
271
272    #[rstest]
273    #[case(NameSort::default(), "Name")]
274    fn test_sort_mode_display(#[case] mode: NameSort<Artist>, #[case] expected: &str) {
275        assert_eq!(mode.to_string(), expected);
276    }
277
278    #[rstest]
279    fn test_sort_items() {
280        let mut artists = vec![
281            Artist {
282                id: Artist::generate_id(),
283                name: "C".into(),
284                song_count: 1,
285                album_count: 1,
286                runtime: Duration::from_secs(180),
287            },
288            Artist {
289                id: Artist::generate_id(),
290                name: "B".into(),
291                song_count: 1,
292                album_count: 1,
293                runtime: Duration::from_secs(180),
294            },
295            Artist {
296                id: Artist::generate_id(),
297                name: "A".into(),
298                song_count: 1,
299                album_count: 1,
300                runtime: Duration::from_secs(180),
301            },
302        ];
303
304        NameSort::default().sort_items(&mut artists);
305        assert_eq!(artists[0].name, "A".into());
306        assert_eq!(artists[1].name, "B".into());
307        assert_eq!(artists[2].name, "C".into());
308    }
309}
310
311#[cfg(test)]
312mod item_view_tests {
313    use super::*;
314    use crate::test_utils::{
315        assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
316    };
317    use anyhow::Result;
318    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
319    use pretty_assertions::assert_eq;
320    use ratatui::buffer::Buffer;
321
322    #[test]
323    fn test_new() {
324        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
325        let state = state_with_everything();
326        let view = ArtistView::new(&state, tx);
327
328        assert_eq!(view.name(), "Artist View");
329        assert_eq!(view.props, Some(state.additional_view_data.artist.unwrap()));
330    }
331
332    #[test]
333    fn test_move_with_state() {
334        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
335        let state = AppState::default();
336        let new_state = state_with_everything();
337        let view = ArtistView::new(&state, tx).move_with_state(&new_state);
338
339        assert_eq!(
340            view.props,
341            Some(new_state.additional_view_data.artist.unwrap())
342        );
343    }
344
345    #[test]
346    fn test_render_no_artist() -> Result<()> {
347        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
348        let view = ArtistView::new(&AppState::default(), tx);
349
350        let (mut terminal, area) = setup_test_terminal(18, 3);
351        let props = RenderProps {
352            area,
353            is_focused: true,
354        };
355        let buffer = terminal
356            .draw(|frame| view.render(frame, props))
357            .unwrap()
358            .buffer
359            .clone();
360        #[rustfmt::skip]
361        let expected = Buffer::with_lines([
362            "┌Artist View─────┐",
363            "│No active artist│",
364            "└────────────────┘",
365        ]);
366
367        assert_buffer_eq(&buffer, &expected);
368
369        Ok(())
370    }
371
372    #[test]
373    fn test_render() -> Result<()> {
374        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
375        let view = ArtistView::new(&state_with_everything(), tx);
376
377        let (mut terminal, area) = setup_test_terminal(60, 9);
378        let props = RenderProps {
379            area,
380            is_focused: true,
381        };
382        let buffer = terminal
383            .draw(|frame| view.render(frame, props))
384            .unwrap()
385            .buffer
386            .clone();
387        let expected = Buffer::with_lines([
388            "┌Artist View───────────────────────────────────────────────┐",
389            "│                        Test Artist                       │",
390            "│        Albums: 1  Songs: 1  Duration: 00:03:00.00        │",
391            "│                                                          │",
392            "│q: add to queue | r: start radio | p: add to playlist─────│",
393            "│Performing operations on entire artist────────────────────│",
394            "│▶ Albums (1):                                             │",
395            "│▶ Songs (1):                                              │",
396            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
397        ]);
398
399        assert_buffer_eq(&buffer, &expected);
400
401        Ok(())
402    }
403
404    #[test]
405    fn test_render_with_checked() -> Result<()> {
406        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
407        let mut view = ArtistView::new(&state_with_everything(), tx);
408        let (mut terminal, area) = setup_test_terminal(60, 9);
409        let props = RenderProps {
410            area,
411            is_focused: true,
412        };
413        let buffer = terminal
414            .draw(|frame| view.render(frame, props))
415            .unwrap()
416            .buffer
417            .clone();
418        let expected = Buffer::with_lines([
419            "┌Artist View───────────────────────────────────────────────┐",
420            "│                        Test Artist                       │",
421            "│        Albums: 1  Songs: 1  Duration: 00:03:00.00        │",
422            "│                                                          │",
423            "│q: add to queue | r: start radio | p: add to playlist─────│",
424            "│Performing operations on entire artist────────────────────│",
425            "│▶ Albums (1):                                             │",
426            "│▶ Songs (1):                                              │",
427            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
428        ]);
429        assert_buffer_eq(&buffer, &expected);
430
431        // select the song
432        view.handle_key_event(KeyEvent::from(KeyCode::Down));
433        view.handle_key_event(KeyEvent::from(KeyCode::Down));
434        view.handle_key_event(KeyEvent::from(KeyCode::Right));
435        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
436        view.handle_key_event(KeyEvent::from(KeyCode::Down));
437        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
438
439        let buffer = terminal
440            .draw(|frame| view.render(frame, props))
441            .unwrap()
442            .buffer
443            .clone();
444        let expected = Buffer::with_lines([
445            "┌Artist View───────────────────────────────────────────────┐",
446            "│                        Test Artist                       │",
447            "│        Albums: 1  Songs: 1  Duration: 00:03:00.00        │",
448            "│                                                          │",
449            "│q: add to queue | r: start radio | p: add to playlist─────│",
450            "│Performing operations on checked items────────────────────│",
451            "│▼ Songs (1):                                              │",
452            "│  ☑ Test Song Test Artist                                 │",
453            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
454        ]);
455
456        assert_buffer_eq(&buffer, &expected);
457
458        Ok(())
459    }
460
461    #[test]
462    fn smoke_navigation() {
463        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
464        let mut view = ArtistView::new(&state_with_everything(), tx);
465
466        view.handle_key_event(KeyEvent::from(KeyCode::Up));
467        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
468        view.handle_key_event(KeyEvent::from(KeyCode::Down));
469        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
470        view.handle_key_event(KeyEvent::from(KeyCode::Left));
471        view.handle_key_event(KeyEvent::from(KeyCode::Right));
472    }
473
474    #[test]
475    fn test_actions() {
476        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
477        let mut view = ArtistView::new(&state_with_everything(), tx);
478
479        // need to render the view at least once to load the tree state
480        let (mut terminal, area) = setup_test_terminal(60, 9);
481        let props = RenderProps {
482            area,
483            is_focused: true,
484        };
485        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
486
487        // we test the actions when:
488        // there are no checked items
489        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
490        assert_eq!(
491            rx.blocking_recv().unwrap(),
492            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
493                "artist",
494                item_id()
495            )
496                .into()])))
497        );
498        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
499        assert_eq!(
500            rx.blocking_recv().unwrap(),
501            Action::ActiveView(ViewAction::Set(ActiveView::Radio(
502                vec![("artist", item_id()).into()],
503                RADIO_SIZE
504            )))
505        );
506        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
507        assert_eq!(
508            rx.blocking_recv().unwrap(),
509            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
510                "artist",
511                item_id()
512            )
513                .into()])))
514        );
515
516        // there are checked items
517        // first we need to select an item
518        view.handle_key_event(KeyEvent::from(KeyCode::Down));
519        view.handle_key_event(KeyEvent::from(KeyCode::Down));
520        view.handle_key_event(KeyEvent::from(KeyCode::Right));
521        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
522        view.handle_key_event(KeyEvent::from(KeyCode::Down));
523
524        // open the selected view
525        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
526        assert_eq!(
527            rx.blocking_recv().unwrap(),
528            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
529        );
530
531        // check the item
532        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
533
534        // add to queue
535        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
536        assert_eq!(
537            rx.blocking_recv().unwrap(),
538            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
539                "song",
540                item_id()
541            )
542                .into()])))
543        );
544
545        // start radio
546        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
547        assert_eq!(
548            rx.blocking_recv().unwrap(),
549            Action::ActiveView(ViewAction::Set(ActiveView::Radio(
550                vec![("song", item_id()).into()],
551                RADIO_SIZE
552            )))
553        );
554
555        // add to playlist
556        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
557        assert_eq!(
558            rx.blocking_recv().unwrap(),
559            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
560                "song",
561                item_id()
562            )
563                .into()])))
564        );
565    }
566
567    #[test]
568    fn test_mouse_event() {
569        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
570        let mut view = ArtistView::new(&state_with_everything(), tx);
571
572        // need to render the view at least once to load the tree state
573        let (mut terminal, area) = setup_test_terminal(60, 9);
574        let props = RenderProps {
575            area,
576            is_focused: true,
577        };
578        let buffer = terminal
579            .draw(|frame| view.render(frame, props))
580            .unwrap()
581            .buffer
582            .clone();
583        let expected = Buffer::with_lines([
584            "┌Artist View───────────────────────────────────────────────┐",
585            "│                        Test Artist                       │",
586            "│        Albums: 1  Songs: 1  Duration: 00:03:00.00        │",
587            "│                                                          │",
588            "│q: add to queue | r: start radio | p: add to playlist─────│",
589            "│Performing operations on entire artist────────────────────│",
590            "│▶ Albums (1):                                             │",
591            "│▶ Songs (1):                                              │",
592            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
593        ]);
594        assert_buffer_eq(&buffer, &expected);
595
596        // click on the dropdown
597        view.handle_mouse_event(
598            MouseEvent {
599                kind: MouseEventKind::Down(MouseButton::Left),
600                column: 2,
601                row: 6,
602                modifiers: KeyModifiers::empty(),
603            },
604            area,
605        );
606        let buffer = terminal
607            .draw(|frame| view.render(frame, props))
608            .unwrap()
609            .buffer
610            .clone();
611        let expected = Buffer::with_lines([
612            "┌Artist View───────────────────────────────────────────────┐",
613            "│                        Test Artist                       │",
614            "│        Albums: 1  Songs: 1  Duration: 00:03:00.00        │",
615            "│                                                          │",
616            "│q: add to queue | r: start radio | p: add to playlist─────│",
617            "│Performing operations on entire artist────────────────────│",
618            "│▼ Albums (1):                                             │",
619            "│  ☐ Test Album Test Artist                                │",
620            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
621        ]);
622        assert_buffer_eq(&buffer, &expected);
623
624        // scroll down
625        view.handle_mouse_event(
626            MouseEvent {
627                kind: MouseEventKind::ScrollDown,
628                column: 2,
629                row: 6,
630                modifiers: KeyModifiers::empty(),
631            },
632            area,
633        );
634        let buffer = terminal
635            .draw(|frame| view.render(frame, props))
636            .unwrap()
637            .buffer
638            .clone();
639        assert_buffer_eq(&buffer, &expected);
640
641        // click down the checkbox item (which is already selected thanks to the scroll)
642        view.handle_mouse_event(
643            MouseEvent {
644                kind: MouseEventKind::Down(MouseButton::Left),
645                column: 2,
646                row: 7,
647                modifiers: KeyModifiers::empty(),
648            },
649            area,
650        );
651        assert_eq!(
652            rx.blocking_recv().unwrap(),
653            Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
654        );
655        let buffer = terminal
656            .draw(|frame| view.render(frame, props))
657            .unwrap()
658            .buffer
659            .clone();
660        let expected = Buffer::with_lines([
661            "┌Artist View───────────────────────────────────────────────┐",
662            "│                        Test Artist                       │",
663            "│        Albums: 1  Songs: 1  Duration: 00:03:00.00        │",
664            "│                                                          │",
665            "│q: add to queue | r: start radio | p: add to playlist─────│",
666            "│Performing operations on checked items────────────────────│",
667            "│▼ Albums (1):                                             │",
668            "│  ☑ Test Album Test Artist                                │",
669            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
670        ]);
671        assert_buffer_eq(&buffer, &expected);
672
673        // scroll up
674        view.handle_mouse_event(
675            MouseEvent {
676                kind: MouseEventKind::ScrollUp,
677                column: 2,
678                row: 7,
679                modifiers: KeyModifiers::empty(),
680            },
681            area,
682        );
683        let buffer = terminal
684            .draw(|frame| view.render(frame, props))
685            .unwrap()
686            .buffer
687            .clone();
688        assert_buffer_eq(&buffer, &expected);
689    }
690}
691
692#[cfg(test)]
693mod library_view_tests {
694    use super::*;
695    use crate::test_utils::{
696        assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
697    };
698    use anyhow::Result;
699    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
700    use pretty_assertions::assert_eq;
701    use ratatui::buffer::Buffer;
702
703    #[test]
704    fn test_new() {
705        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
706        let state = state_with_everything();
707        let view = LibraryArtistsView::new(&state, tx);
708
709        assert_eq!(view.name(), "Library Artists View");
710        assert_eq!(view.props.artists, state.library.artists);
711    }
712
713    #[test]
714    fn test_move_with_state() {
715        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
716        let state = AppState::default();
717        let new_state = state_with_everything();
718        let view = LibraryArtistsView::new(&state, tx).move_with_state(&new_state);
719
720        assert_eq!(view.props.artists, new_state.library.artists);
721    }
722
723    #[test]
724    fn test_render() -> Result<()> {
725        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
726        let view = LibraryArtistsView::new(&state_with_everything(), tx);
727
728        let (mut terminal, area) = setup_test_terminal(60, 6);
729        let props = RenderProps {
730            area,
731            is_focused: true,
732        };
733        let buffer = terminal
734            .draw(|frame| view.render(frame, props))
735            .unwrap()
736            .buffer
737            .clone();
738        let expected = Buffer::with_lines([
739            "┌Library Artists sorted by: Name───────────────────────────┐",
740            "│──────────────────────────────────────────────────────────│",
741            "│☐ Test Artist                                             │",
742            "│                                                          │",
743            "│s/S: change sort──────────────────────────────────────────│",
744            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
745        ]);
746
747        assert_buffer_eq(&buffer, &expected);
748
749        Ok(())
750    }
751
752    #[test]
753    fn test_render_with_checked() -> Result<()> {
754        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
755        let mut view = LibraryArtistsView::new(&state_with_everything(), tx);
756        let (mut terminal, area) = setup_test_terminal(60, 6);
757        let props = RenderProps {
758            area,
759            is_focused: true,
760        };
761        let buffer = terminal
762            .draw(|frame| view.render(frame, props))
763            .unwrap()
764            .buffer
765            .clone();
766        let expected = Buffer::with_lines([
767            "┌Library Artists sorted by: Name───────────────────────────┐",
768            "│──────────────────────────────────────────────────────────│",
769            "│☐ Test Artist                                             │",
770            "│                                                          │",
771            "│s/S: change sort──────────────────────────────────────────│",
772            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
773        ]);
774        assert_buffer_eq(&buffer, &expected);
775
776        // check the first artist
777        view.handle_key_event(KeyEvent::from(KeyCode::Down));
778        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
779
780        let buffer = terminal
781            .draw(|frame| view.render(frame, props))
782            .unwrap()
783            .buffer
784            .clone();
785        let expected = Buffer::with_lines([
786            "┌Library Artists sorted by: Name───────────────────────────┐",
787            "│q: add to queue | r: start radio | p: add to playlist ────│",
788            "│☑ Test Artist                                             │",
789            "│                                                          │",
790            "│s/S: change sort──────────────────────────────────────────│",
791            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
792        ]);
793
794        assert_buffer_eq(&buffer, &expected);
795
796        Ok(())
797    }
798
799    #[test]
800    fn test_sort_keys() {
801        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
802        let mut view = LibraryArtistsView::new(&state_with_everything(), tx);
803
804        assert_eq!(view.props.sort_mode, NameSort::default());
805        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
806        assert_eq!(view.props.sort_mode, NameSort::default());
807        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
808        assert_eq!(view.props.sort_mode, NameSort::default());
809    }
810
811    #[test]
812    fn smoke_navigation() {
813        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
814        let mut view = LibraryArtistsView::new(&state_with_everything(), tx);
815
816        view.handle_key_event(KeyEvent::from(KeyCode::Up));
817        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
818        view.handle_key_event(KeyEvent::from(KeyCode::Down));
819        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
820        view.handle_key_event(KeyEvent::from(KeyCode::Left));
821        view.handle_key_event(KeyEvent::from(KeyCode::Right));
822    }
823
824    #[test]
825    fn test_actions() {
826        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
827        let mut view = LibraryArtistsView::new(&state_with_everything(), tx);
828
829        // need to render the view at least once to load the tree state
830        let (mut terminal, area) = setup_test_terminal(60, 9);
831        let props = RenderProps {
832            area,
833            is_focused: true,
834        };
835        terminal.draw(|frame| view.render(frame, props)).unwrap();
836
837        // first we need to navigate to the artist
838        view.handle_key_event(KeyEvent::from(KeyCode::Down));
839
840        // now, we test the actions that require checked items when:
841        // there are no checked items (order is different so that if an action is performed, the assertion later will fail)
842        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
843        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
844        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
845        // open
846        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
847        let action = rx.blocking_recv().unwrap();
848        assert_eq!(
849            action,
850            Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
851        );
852
853        // there are checked items
854        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
855
856        // add to queue
857        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
858        let action = rx.blocking_recv().unwrap();
859        assert_eq!(
860            action,
861            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![(
862                "artist",
863                item_id()
864            )
865                .into()])))
866        );
867
868        // start radio
869        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
870        let action = rx.blocking_recv().unwrap();
871        assert_eq!(
872            action,
873            Action::ActiveView(ViewAction::Set(ActiveView::Radio(
874                vec![("artist", item_id()).into()],
875                RADIO_SIZE
876            )))
877        );
878
879        // add to playlist
880        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
881        let action = rx.blocking_recv().unwrap();
882        assert_eq!(
883            action,
884            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![(
885                "artist",
886                item_id()
887            )
888                .into()])))
889        );
890    }
891
892    #[test]
893    fn test_mouse() {
894        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
895        let mut view = LibraryArtistsView::new(&state_with_everything(), tx);
896
897        // need to render the view at least once to load the tree state
898        let (mut terminal, area) = setup_test_terminal(60, 6);
899        let props = RenderProps {
900            area,
901            is_focused: true,
902        };
903        let buffer = terminal
904            .draw(|frame| view.render(frame, props))
905            .unwrap()
906            .buffer
907            .clone();
908        let expected = Buffer::with_lines([
909            "┌Library Artists sorted by: Name───────────────────────────┐",
910            "│──────────────────────────────────────────────────────────│",
911            "│☐ Test Artist                                             │",
912            "│                                                          │",
913            "│s/S: change sort──────────────────────────────────────────│",
914            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
915        ]);
916        assert_buffer_eq(&buffer, &expected);
917
918        // click on the album
919        view.handle_mouse_event(
920            MouseEvent {
921                kind: MouseEventKind::Down(MouseButton::Left),
922                column: 2,
923                row: 2,
924                modifiers: KeyModifiers::empty(),
925            },
926            area,
927        );
928        let buffer = terminal
929            .draw(|frame| view.render(frame, props))
930            .unwrap()
931            .buffer
932            .clone();
933        let expected = Buffer::with_lines([
934            "┌Library Artists sorted by: Name───────────────────────────┐",
935            "│q: add to queue | r: start radio | p: add to playlist ────│",
936            "│☑ Test Artist                                             │",
937            "│                                                          │",
938            "│s/S: change sort──────────────────────────────────────────│",
939            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
940        ]);
941        assert_buffer_eq(&buffer, &expected);
942
943        // scroll down
944        view.handle_mouse_event(
945            MouseEvent {
946                kind: MouseEventKind::ScrollDown,
947                column: 2,
948                row: 2,
949                modifiers: KeyModifiers::empty(),
950            },
951            area,
952        );
953        let buffer = terminal
954            .draw(|frame| view.render(frame, props))
955            .unwrap()
956            .buffer
957            .clone();
958        assert_buffer_eq(&buffer, &expected);
959
960        // scroll up
961        view.handle_mouse_event(
962            MouseEvent {
963                kind: MouseEventKind::ScrollUp,
964                column: 2,
965                row: 2,
966                modifiers: KeyModifiers::empty(),
967            },
968            area,
969        );
970        let buffer = terminal
971            .draw(|frame| view.render(frame, props))
972            .unwrap()
973            .buffer
974            .clone();
975        assert_buffer_eq(&buffer, &expected);
976
977        // click down on selected item
978        view.handle_mouse_event(
979            MouseEvent {
980                kind: MouseEventKind::Down(MouseButton::Left),
981                column: 2,
982                row: 2,
983                modifiers: KeyModifiers::empty(),
984            },
985            area,
986        );
987        assert_eq!(
988            rx.blocking_recv().unwrap(),
989            Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
990        );
991
992        // clicking on an empty area should clear the selection
993        let mouse = MouseEvent {
994            kind: MouseEventKind::Down(MouseButton::Left),
995            column: 2,
996            row: 3,
997            modifiers: KeyModifiers::empty(),
998        };
999        view.handle_mouse_event(mouse, area);
1000        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1001        view.handle_mouse_event(mouse, area);
1002        assert_eq!(
1003            rx.try_recv(),
1004            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1005        );
1006    }
1007}