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