Skip to main content

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

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