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