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

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