Skip to main content

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

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