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