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

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