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::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        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<[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 = (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 one_or_many::OneOrMany;
255    use pretty_assertions::assert_eq;
256    use rstest::rstest;
257    use std::time::Duration;
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            Album {
280                id: Album::generate_id(),
281                title: "C".into(),
282                artist: OneOrMany::One("B".into()),
283                release: Some(2021),
284                song_count: 1,
285                runtime: Duration::from_secs(180),
286                discs: 1,
287                genre: OneOrMany::One("A".into()),
288            },
289            Album {
290                id: Album::generate_id(),
291                title: "B".into(),
292                artist: OneOrMany::One("A".into()),
293                release: Some(2022),
294                song_count: 1,
295                runtime: Duration::from_secs(180),
296                discs: 1,
297                genre: OneOrMany::One("C".into()),
298            },
299            Album {
300                id: Album::generate_id(),
301                title: "A".into(),
302                artist: OneOrMany::One("C".into()),
303                release: Some(2023),
304                song_count: 1,
305                runtime: Duration::from_secs(180),
306                discs: 1,
307                genre: OneOrMany::One("B".into()),
308            },
309        ];
310
311        AlbumSort::Title.sort_items(&mut albums);
312        assert_eq!(albums[0].title, "A");
313        assert_eq!(albums[1].title, "B");
314        assert_eq!(albums[2].title, "C");
315
316        AlbumSort::Artist.sort_items(&mut albums);
317        assert_eq!(albums[0].artist, OneOrMany::One("A".into()));
318        assert_eq!(albums[1].artist, OneOrMany::One("B".into()));
319        assert_eq!(albums[2].artist, OneOrMany::One("C".into()));
320
321        AlbumSort::ReleaseYear.sort_items(&mut albums);
322        assert_eq!(albums[0].release, Some(2023));
323        assert_eq!(albums[1].release, Some(2022));
324        assert_eq!(albums[2].release, Some(2021));
325    }
326}
327
328#[cfg(test)]
329mod item_view_tests {
330    use super::*;
331    use crate::test_utils::{
332        assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
333    };
334    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
335    use pretty_assertions::assert_eq;
336    use ratatui::buffer::Buffer;
337
338    #[test]
339    fn test_new() {
340        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
341        let state = state_with_everything();
342        let view = AlbumView::new(&state, tx);
343
344        assert_eq!(view.name(), "Album View");
345        assert_eq!(view.props, Some(state.additional_view_data.album.unwrap()));
346    }
347
348    #[test]
349    fn test_move_with_state() {
350        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
351        let state = AppState::default();
352        let new_state = state_with_everything();
353        let view = AlbumView::new(&state, tx).move_with_state(&new_state);
354
355        assert_eq!(
356            view.props,
357            Some(new_state.additional_view_data.album.unwrap())
358        );
359    }
360
361    #[test]
362    fn test_render_no_album() {
363        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
364        let view = AlbumView::new(&AppState::default(), tx);
365
366        let (mut terminal, area) = setup_test_terminal(17, 3);
367        let props = RenderProps {
368            area,
369            is_focused: true,
370        };
371        let buffer = terminal
372            .draw(|frame| view.render(frame, props))
373            .unwrap()
374            .buffer
375            .clone();
376        #[rustfmt::skip]
377        let expected = Buffer::with_lines([
378            "┌Album View─────┐",
379            "│No active album│",
380            "└───────────────┘",
381        ]);
382
383        assert_buffer_eq(&buffer, &expected);
384    }
385
386    #[test]
387    fn test_render() {
388        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
389        let view = AlbumView::new(&state_with_everything(), tx);
390
391        let (mut terminal, area) = setup_test_terminal(60, 9);
392        let props = RenderProps {
393            area,
394            is_focused: true,
395        };
396        let buffer = terminal
397            .draw(|frame| view.render(frame, props))
398            .unwrap()
399            .buffer
400            .clone();
401        let expected = Buffer::with_lines([
402            "┌Album View────────────────────────────────────────────────┐",
403            "│                  Test Album Test Artist                  │",
404            "│    Release Year: 2021  Songs: 1  Duration: 00:03:00.00   │",
405            "│                                                          │",
406            "│q: add to queue | r: start radio | p: add to playlist─────│",
407            "│Performing operations on entire album─────────────────────│",
408            "│▶ Artists (1):                                            │",
409            "│▶ Songs (1):                                              │",
410            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
411        ]);
412
413        assert_buffer_eq(&buffer, &expected);
414    }
415
416    #[test]
417    fn test_render_with_checked() {
418        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
419        let mut view = AlbumView::new(&state_with_everything(), tx);
420        let (mut terminal, area) = setup_test_terminal(60, 9);
421        let props = RenderProps {
422            area,
423            is_focused: true,
424        };
425        let buffer = terminal
426            .draw(|frame| view.render(frame, props))
427            .unwrap()
428            .buffer
429            .clone();
430        let expected = Buffer::with_lines([
431            "┌Album View────────────────────────────────────────────────┐",
432            "│                  Test Album Test Artist                  │",
433            "│    Release Year: 2021  Songs: 1  Duration: 00:03:00.00   │",
434            "│                                                          │",
435            "│q: add to queue | r: start radio | p: add to playlist─────│",
436            "│Performing operations on entire album─────────────────────│",
437            "│▶ Artists (1):                                            │",
438            "│▶ Songs (1):                                              │",
439            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
440        ]);
441        assert_buffer_eq(&buffer, &expected);
442
443        // select the song
444        view.handle_key_event(KeyEvent::from(KeyCode::Down));
445        view.handle_key_event(KeyEvent::from(KeyCode::Down));
446        view.handle_key_event(KeyEvent::from(KeyCode::Right));
447        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
448        view.handle_key_event(KeyEvent::from(KeyCode::Down));
449        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
450
451        let buffer = terminal
452            .draw(|frame| view.render(frame, props))
453            .unwrap()
454            .buffer
455            .clone();
456        let expected = Buffer::with_lines([
457            "┌Album View────────────────────────────────────────────────┐",
458            "│                  Test Album Test Artist                  │",
459            "│    Release Year: 2021  Songs: 1  Duration: 00:03:00.00   │",
460            "│                                                          │",
461            "│q: add to queue | r: start radio | p: add to playlist─────│",
462            "│Performing operations on checked items────────────────────│",
463            "│▼ Songs (1):                                              │",
464            "│  ☑ Test Song Test Artist                                 │",
465            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
466        ]);
467
468        assert_buffer_eq(&buffer, &expected);
469    }
470
471    #[test]
472    fn smoke_navigation() {
473        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
474        let mut view = AlbumView::new(&state_with_everything(), tx);
475
476        view.handle_key_event(KeyEvent::from(KeyCode::Up));
477        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
478        view.handle_key_event(KeyEvent::from(KeyCode::Down));
479        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
480        view.handle_key_event(KeyEvent::from(KeyCode::Left));
481        view.handle_key_event(KeyEvent::from(KeyCode::Right));
482    }
483
484    #[test]
485    fn test_actions() {
486        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
487        let mut view = AlbumView::new(&state_with_everything(), tx);
488
489        // need to render the view at least once to load the tree state
490        let (mut terminal, area) = setup_test_terminal(60, 9);
491        let props = RenderProps {
492            area,
493            is_focused: true,
494        };
495        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
496
497        // we test the actions when:
498        // there are no checked items
499        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
500        assert_eq!(
501            rx.blocking_recv().unwrap(),
502            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
503                ("album", item_id()).into()
504            ])))
505        );
506        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
507        assert_eq!(
508            rx.blocking_recv().unwrap(),
509            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
510                ("album", item_id()).into()
511            ],)))
512        );
513        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
514        assert_eq!(
515            rx.blocking_recv().unwrap(),
516            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
517                ("album", item_id()).into()
518            ])))
519        );
520
521        // there are checked items
522        // first we need to select an item
523        view.handle_key_event(KeyEvent::from(KeyCode::Down));
524        view.handle_key_event(KeyEvent::from(KeyCode::Down));
525        view.handle_key_event(KeyEvent::from(KeyCode::Right));
526        let _frame = terminal.draw(|frame| view.render(frame, props)).unwrap();
527        view.handle_key_event(KeyEvent::from(KeyCode::Down));
528
529        // open the selected view
530        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
531        assert_eq!(
532            rx.blocking_recv().unwrap(),
533            Action::ActiveView(ViewAction::Set(ActiveView::Song(item_id())))
534        );
535
536        // check the item
537        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
538
539        // add to queue
540        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
541        assert_eq!(
542            rx.blocking_recv().unwrap(),
543            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
544                ("song", item_id()).into()
545            ])))
546        );
547
548        // start radio
549        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
550        assert_eq!(
551            rx.blocking_recv().unwrap(),
552            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
553                ("song", item_id()).into()
554            ],)))
555        );
556
557        // add to playlist
558        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
559        assert_eq!(
560            rx.blocking_recv().unwrap(),
561            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
562                ("song", item_id()).into()
563            ])))
564        );
565    }
566
567    #[test]
568    #[allow(clippy::too_many_lines)]
569    fn test_mouse() {
570        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
571        let mut view = AlbumView::new(&state_with_everything(), tx);
572
573        // need to render the view at least once to load the tree state
574        let (mut terminal, area) = setup_test_terminal(60, 9);
575        let props = RenderProps {
576            area,
577            is_focused: true,
578        };
579        let buffer = terminal
580            .draw(|frame| view.render(frame, props))
581            .unwrap()
582            .buffer
583            .clone();
584        let expected = Buffer::with_lines([
585            "┌Album View────────────────────────────────────────────────┐",
586            "│                  Test Album Test Artist                  │",
587            "│    Release Year: 2021  Songs: 1  Duration: 00:03:00.00   │",
588            "│                                                          │",
589            "│q: add to queue | r: start radio | p: add to playlist─────│",
590            "│Performing operations on entire album─────────────────────│",
591            "│▶ Artists (1):                                            │",
592            "│▶ Songs (1):                                              │",
593            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
594        ]);
595        assert_buffer_eq(&buffer, &expected);
596
597        // click on the artist dropdown
598        view.handle_mouse_event(
599            MouseEvent {
600                kind: MouseEventKind::Down(MouseButton::Left),
601                column: 2,
602                row: 6,
603                modifiers: KeyModifiers::empty(),
604            },
605            area,
606        );
607        let buffer = terminal
608            .draw(|frame| view.render(frame, props))
609            .unwrap()
610            .buffer
611            .clone();
612        let expected = Buffer::with_lines([
613            "┌Album View────────────────────────────────────────────────┐",
614            "│                  Test Album Test Artist                  │",
615            "│    Release Year: 2021  Songs: 1  Duration: 00:03:00.00   │",
616            "│                                                          │",
617            "│q: add to queue | r: start radio | p: add to playlist─────│",
618            "│Performing operations on entire album─────────────────────│",
619            "│▼ Artists (1):                                            │",
620            "│  ☐ Test Artist                                           │",
621            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
622        ]);
623        assert_buffer_eq(&buffer, &expected);
624
625        // scroll down
626        view.handle_mouse_event(
627            MouseEvent {
628                kind: MouseEventKind::ScrollDown,
629                column: 2,
630                row: 6,
631                modifiers: KeyModifiers::empty(),
632            },
633            area,
634        );
635        let buffer = terminal
636            .draw(|frame| view.render(frame, props))
637            .unwrap()
638            .buffer
639            .clone();
640        assert_buffer_eq(&buffer, &expected);
641
642        // click down the artist item (which is already selected thanks to the scroll)
643        view.handle_mouse_event(
644            MouseEvent {
645                kind: MouseEventKind::Down(MouseButton::Left),
646                column: 2,
647                row: 7,
648                modifiers: KeyModifiers::empty(),
649            },
650            area,
651        );
652        assert_eq!(
653            rx.blocking_recv().unwrap(),
654            Action::ActiveView(ViewAction::Set(ActiveView::Artist(item_id())))
655        );
656        let buffer = terminal
657            .draw(|frame| view.render(frame, props))
658            .unwrap()
659            .buffer
660            .clone();
661        let expected = Buffer::with_lines([
662            "┌Album View────────────────────────────────────────────────┐",
663            "│                  Test Album Test Artist                  │",
664            "│    Release Year: 2021  Songs: 1  Duration: 00:03:00.00   │",
665            "│                                                          │",
666            "│q: add to queue | r: start radio | p: add to playlist─────│",
667            "│Performing operations on checked items────────────────────│",
668            "│▼ Artists (1):                                            │",
669            "│  ☑ Test Artist                                           │",
670            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
671        ]);
672        assert_buffer_eq(&buffer, &expected);
673
674        // scroll up
675        view.handle_mouse_event(
676            MouseEvent {
677                kind: MouseEventKind::ScrollUp,
678                column: 2,
679                row: 7,
680                modifiers: KeyModifiers::empty(),
681            },
682            area,
683        );
684        let buffer = terminal
685            .draw(|frame| view.render(frame, props))
686            .unwrap()
687            .buffer
688            .clone();
689        assert_buffer_eq(&buffer, &expected);
690    }
691}
692
693#[cfg(test)]
694mod library_view_tests {
695    use super::*;
696    use crate::test_utils::{
697        assert_buffer_eq, item_id, setup_test_terminal, state_with_everything,
698    };
699    use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
700    use pretty_assertions::assert_eq;
701    use ratatui::buffer::Buffer;
702
703    #[test]
704    fn test_new() {
705        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
706        let state = state_with_everything();
707        let view = LibraryAlbumsView::new(&state, tx);
708
709        assert_eq!(view.name(), "Library Albums View");
710        assert_eq!(view.props.albums, state.library.albums);
711    }
712
713    #[test]
714    fn test_move_with_state() {
715        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
716        let state = AppState::default();
717        let new_state = state_with_everything();
718        let view = LibraryAlbumsView::new(&state, tx).move_with_state(&new_state);
719
720        assert_eq!(view.props.albums, new_state.library.albums);
721    }
722
723    #[test]
724    fn test_render() {
725        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
726        let view = LibraryAlbumsView::new(&state_with_everything(), tx);
727
728        let (mut terminal, area) = setup_test_terminal(60, 6);
729        let props = RenderProps {
730            area,
731            is_focused: true,
732        };
733        let buffer = terminal
734            .draw(|frame| view.render(frame, props))
735            .unwrap()
736            .buffer
737            .clone();
738        let expected = Buffer::with_lines([
739            "┌Library Albums sorted by: Artist──────────────────────────┐",
740            "│──────────────────────────────────────────────────────────│",
741            "│☐ Test Album Test Artist                                  │",
742            "│                                                          │",
743            "│s/S: change sort──────────────────────────────────────────│",
744            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
745        ]);
746
747        assert_buffer_eq(&buffer, &expected);
748    }
749
750    #[test]
751    fn test_render_with_checked() {
752        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
753        let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
754        let (mut terminal, area) = setup_test_terminal(60, 6);
755        let props = RenderProps {
756            area,
757            is_focused: true,
758        };
759        let buffer = terminal
760            .draw(|frame| view.render(frame, props))
761            .unwrap()
762            .buffer
763            .clone();
764        let expected = Buffer::with_lines([
765            "┌Library Albums sorted by: Artist──────────────────────────┐",
766            "│──────────────────────────────────────────────────────────│",
767            "│☐ Test Album Test Artist                                  │",
768            "│                                                          │",
769            "│s/S: change sort──────────────────────────────────────────│",
770            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
771        ]);
772        assert_buffer_eq(&buffer, &expected);
773
774        // check the first album
775        view.handle_key_event(KeyEvent::from(KeyCode::Down));
776        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
777
778        let buffer = terminal
779            .draw(|frame| view.render(frame, props))
780            .unwrap()
781            .buffer
782            .clone();
783        let expected = Buffer::with_lines([
784            "┌Library Albums sorted by: Artist──────────────────────────┐",
785            "│q: add to queue | r: start radio | p: add to playlist ────│",
786            "│☑ Test Album Test Artist                                  │",
787            "│                                                          │",
788            "│s/S: change sort──────────────────────────────────────────│",
789            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
790        ]);
791
792        assert_buffer_eq(&buffer, &expected);
793    }
794
795    #[test]
796    fn test_sort_keys() {
797        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
798        let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
799
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::ReleaseYear);
803        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
804        assert_eq!(view.props.sort_mode, AlbumSort::Title);
805        view.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
806        assert_eq!(view.props.sort_mode, AlbumSort::Artist);
807        view.handle_key_event(KeyEvent::from(KeyCode::Char('S')));
808        assert_eq!(view.props.sort_mode, AlbumSort::Title);
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::Artist);
813    }
814
815    #[test]
816    fn smoke_navigation() {
817        let (tx, _) = tokio::sync::mpsc::unbounded_channel();
818        let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
819
820        view.handle_key_event(KeyEvent::from(KeyCode::Up));
821        view.handle_key_event(KeyEvent::from(KeyCode::PageUp));
822        view.handle_key_event(KeyEvent::from(KeyCode::Down));
823        view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
824        view.handle_key_event(KeyEvent::from(KeyCode::Left));
825        view.handle_key_event(KeyEvent::from(KeyCode::Right));
826    }
827
828    #[test]
829    fn test_actions() {
830        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
831        let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
832
833        // need to render the view at least once to load the tree state
834        let (mut terminal, area) = setup_test_terminal(60, 9);
835        let props = RenderProps {
836            area,
837            is_focused: true,
838        };
839        terminal.draw(|frame| view.render(frame, props)).unwrap();
840
841        // first we need to navigate to the album
842        view.handle_key_event(KeyEvent::from(KeyCode::Down));
843
844        // now, we test the actions that require checked items when:
845        // there are no checked items (order is different so that if an action is performed, the assertion later will fail)
846        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
847        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
848        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
849        // open
850        view.handle_key_event(KeyEvent::from(KeyCode::Enter));
851        let action = rx.blocking_recv().unwrap();
852        assert_eq!(
853            action,
854            Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
855        );
856
857        // there are checked items
858        view.handle_key_event(KeyEvent::from(KeyCode::Char(' ')));
859
860        // add to queue
861        view.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
862        let action = rx.blocking_recv().unwrap();
863        assert_eq!(
864            action,
865            Action::Audio(AudioAction::Queue(QueueAction::Add(vec![
866                ("album", item_id()).into()
867            ])))
868        );
869
870        // start radio
871        view.handle_key_event(KeyEvent::from(KeyCode::Char('r')));
872        let action = rx.blocking_recv().unwrap();
873        assert_eq!(
874            action,
875            Action::ActiveView(ViewAction::Set(ActiveView::Radio(vec![
876                ("album", item_id()).into()
877            ],)))
878        );
879
880        // add to playlist
881        view.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
882        let action = rx.blocking_recv().unwrap();
883        assert_eq!(
884            action,
885            Action::Popup(PopupAction::Open(PopupType::Playlist(vec![
886                ("album", item_id()).into()
887            ])))
888        );
889    }
890
891    #[test]
892    fn test_mouse() {
893        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
894        let mut view = LibraryAlbumsView::new(&state_with_everything(), tx);
895
896        // need to render the view at least once to load the tree state
897        let (mut terminal, area) = setup_test_terminal(60, 6);
898        let props = RenderProps {
899            area,
900            is_focused: true,
901        };
902        let buffer = terminal
903            .draw(|frame| view.render(frame, props))
904            .unwrap()
905            .buffer
906            .clone();
907        let expected = Buffer::with_lines([
908            "┌Library Albums sorted by: Artist──────────────────────────┐",
909            "│──────────────────────────────────────────────────────────│",
910            "│☐ Test Album Test Artist                                  │",
911            "│                                                          │",
912            "│s/S: change sort──────────────────────────────────────────│",
913            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
914        ]);
915        assert_buffer_eq(&buffer, &expected);
916
917        // click on the album
918        view.handle_mouse_event(
919            MouseEvent {
920                kind: MouseEventKind::Down(MouseButton::Left),
921                column: 2,
922                row: 2,
923                modifiers: KeyModifiers::empty(),
924            },
925            area,
926        );
927        let buffer = terminal
928            .draw(|frame| view.render(frame, props))
929            .unwrap()
930            .buffer
931            .clone();
932        let expected = Buffer::with_lines([
933            "┌Library Albums sorted by: Artist──────────────────────────┐",
934            "│q: add to queue | r: start radio | p: add to playlist ────│",
935            "│☑ Test Album Test Artist                                  │",
936            "│                                                          │",
937            "│s/S: change sort──────────────────────────────────────────│",
938            "└ ⏎ : Open | ←/↑/↓/→: Navigate | ␣ Check───────────────────┘",
939        ]);
940        assert_buffer_eq(&buffer, &expected);
941
942        // scroll down
943        view.handle_mouse_event(
944            MouseEvent {
945                kind: MouseEventKind::ScrollDown,
946                column: 2,
947                row: 2,
948                modifiers: KeyModifiers::empty(),
949            },
950            area,
951        );
952        let buffer = terminal
953            .draw(|frame| view.render(frame, props))
954            .unwrap()
955            .buffer
956            .clone();
957        assert_buffer_eq(&buffer, &expected);
958
959        // scroll up
960        view.handle_mouse_event(
961            MouseEvent {
962                kind: MouseEventKind::ScrollUp,
963                column: 2,
964                row: 2,
965                modifiers: KeyModifiers::empty(),
966            },
967            area,
968        );
969        let buffer = terminal
970            .draw(|frame| view.render(frame, props))
971            .unwrap()
972            .buffer
973            .clone();
974        assert_buffer_eq(&buffer, &expected);
975
976        // click down on selected item
977        view.handle_mouse_event(
978            MouseEvent {
979                kind: MouseEventKind::Down(MouseButton::Left),
980                column: 2,
981                row: 2,
982                modifiers: KeyModifiers::empty(),
983            },
984            area,
985        );
986        assert_eq!(
987            rx.blocking_recv().unwrap(),
988            Action::ActiveView(ViewAction::Set(ActiveView::Album(item_id())))
989        );
990
991        // clicking on an empty area should clear the selection
992        let mouse = MouseEvent {
993            kind: MouseEventKind::Down(MouseButton::Left),
994            column: 2,
995            row: 3,
996            modifiers: KeyModifiers::empty(),
997        };
998        view.handle_mouse_event(mouse, area);
999        assert_eq!(view.tree_state.lock().unwrap().get_selected_thing(), None);
1000        view.handle_mouse_event(mouse, area);
1001        assert_eq!(
1002            rx.try_recv(),
1003            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1004        );
1005    }
1006}