pulsedeck 0.1.7

A cyber-synthwave internet radio player and smart tape recorder for your terminal
use super::*;

const UNDO_HISTORY_LIMIT: usize = 10;

impl App {
    pub(super) fn remove_library_selection(&mut self) {
        if self.input_mode == InputMode::Normal {
            if let Some(station) = self.visible_stations().get(self.selected).copied().cloned() {
                let removed_index = self
                    .library
                    .stations
                    .iter()
                    .position(|saved| saved.url == station.url)
                    .unwrap_or(self.selected);
                let removed_genre = self
                    .library
                    .available_genres
                    .get(self.selected_genre_idx)
                    .cloned()
                    .unwrap_or_else(|| "All".to_string());
                let url = station.url.clone();
                match self.library.remove(&url) {
                    Ok(true) => {
                        self.remember_removed_station(station, removed_index, removed_genre);
                        self.set_info_notice("Station removed. Press u to undo");
                    }
                    Ok(false) => {}
                    Err(err) => {
                        self.remember_removed_station(station, removed_index, removed_genre);
                        self.set_error_notice(format!(
                            "Station removed in memory, but could not save library: {err}"
                        ));
                    }
                }
                let count = self.visible_count();
                if self.selected >= count && self.selected > 0 {
                    self.selected = count - 1;
                }
            }
        }
    }

    pub(super) fn undo_remove_library_selection(&mut self) {
        if self.input_mode != InputMode::Normal {
            return;
        }

        let Some((station, previous_index, previous_genre)) = self.undo_history.pop_back() else {
            self.set_info_notice("Nothing left to undo");
            return;
        };

        if self.library.contains(&station.url) {
            self.set_info_notice("Station already restored");
            return;
        }

        let station_name = station.name.clone();
        let insert_at = previous_index.min(self.library.stations.len());
        self.library.stations.insert(insert_at, station.clone());
        self.library.rebuild_genres();
        if let Some(genre_index) = self
            .library
            .available_genres
            .iter()
            .position(|genre| genre == &previous_genre)
        {
            self.selected_genre_idx = genre_index;
        }
        self.selected = self
            .visible_stations()
            .iter()
            .position(|visible| visible.url == station.url)
            .unwrap_or(0);
        match self.library.save() {
            Ok(()) => self.set_info_notice(format!("Restored station: {station_name}")),
            Err(err) => self.set_error_notice(format!(
                "Station restored in memory, but could not save library: {err}"
            )),
        }
    }

    fn remember_removed_station(&mut self, station: Station, index: usize, genre: String) {
        self.undo_history.push_back((station, index, genre));
        while self.undo_history.len() > UNDO_HISTORY_LIMIT {
            self.undo_history.pop_front();
        }
    }

    pub(super) fn next_genre(&mut self) {
        if self.input_mode == InputMode::Normal {
            let count = self.library.available_genres.len();
            if count > 0 {
                self.selected_genre_idx = (self.selected_genre_idx + 1) % count;
                self.select_playing_station_or_first_visible();
            }
        }
    }

    pub(super) fn prev_genre(&mut self) {
        if self.input_mode == InputMode::Normal {
            let count = self.library.available_genres.len();
            if count > 0 {
                self.selected_genre_idx = if self.selected_genre_idx == 0 {
                    count - 1
                } else {
                    self.selected_genre_idx - 1
                };
                self.select_playing_station_or_first_visible();
            }
        }
    }

    fn select_playing_station_or_first_visible(&mut self) {
        let next_selected = self
            .playing_url
            .as_deref()
            .and_then(|playing_url| {
                self.visible_stations()
                    .iter()
                    .position(|station| station.url == playing_url)
            })
            .unwrap_or(0);

        self.selected = next_selected;
    }

    pub(super) fn save_library_or_notice(&mut self, context: &str) {
        if let Err(err) = self.library.save() {
            self.set_error_notice(format!("Could not save {context}: {err}"));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::action::Action;
    use crate::favorites::Library;
    use crate::radio::Station;

    fn station(name: &str, url: &str, genre: &str) -> Station {
        Station {
            name: name.to_string(),
            url: url.to_string(),
            genre: genre.to_string(),
            country: "US".to_string(),
            bitrate: 128,
        }
    }

    fn notice_text(app: &App) -> Option<&str> {
        match app.notice.as_ref() {
            Some(AppNotice::Info(message)) | Some(AppNotice::Error(message)) => Some(message),
            None => None,
        }
    }

    fn genre_index(app: &App, genre: &str) -> usize {
        app.library
            .available_genres
            .iter()
            .position(|candidate| candidate == genre)
            .unwrap()
    }

    #[test]
    fn remove_library_selection_removes_selected_station() {
        let mut app = App::new(Library::in_memory(vec![
            station("A", "http://a", "Synthwave"),
            station("B", "http://b", "Synthwave"),
        ]));
        app.selected = 0;

        app.remove_library_selection();

        assert!(!app.library.contains("http://a"));
        assert!(app.library.contains("http://b"));
        assert_eq!(notice_text(&app), Some("Station removed. Press u to undo"));
    }

    #[test]
    fn undo_remove_library_selection_restores_last_removed_station() {
        let mut app = App::new(Library::in_memory(vec![
            station("A", "http://a", "Synthwave"),
            station("B", "http://b", "Synthwave"),
        ]));
        app.selected = 0;

        app.update(Action::RemoveLibrarySelection);
        app.update(Action::UndoRemoveLibrarySelection);

        assert_eq!(app.library.stations.len(), 2);
        assert_eq!(app.library.stations[0].name, "A");
        assert_eq!(app.library.stations[1].name, "B");
        assert_eq!(app.selected, 0);
        assert_eq!(notice_text(&app), Some("Restored station: A"));
    }

    #[test]
    fn undo_remove_restores_selection_inside_genre_filter() {
        let mut app = App::new(Library::in_memory(vec![
            station("Synth A", "http://synth-a", "Synthwave"),
            station("Ambient A", "http://ambient-a", "Ambient"),
            station("Synth B", "http://synth-b", "Synthwave"),
        ]));
        app.selected_genre_idx = app
            .library
            .available_genres
            .iter()
            .position(|genre| genre == "Synthwave")
            .unwrap();
        app.selected = 1;

        app.update(Action::RemoveLibrarySelection);
        app.update(Action::UndoRemoveLibrarySelection);

        assert_eq!(app.library.stations[2].name, "Synth B");
        assert_eq!(app.visible_stations()[app.selected].name, "Synth B");
    }

    #[test]
    fn now_playing_uses_undo_snapshot_after_playing_station_removal() {
        let mut app = App::new(Library::in_memory(vec![station(
            "A",
            "http://a",
            "Synthwave",
        )]));
        app.playing_url = Some("http://a".to_string());

        app.update(Action::RemoveLibrarySelection);

        assert_eq!(
            app.now_playing().map(|station| station.name.as_str()),
            Some("A")
        );
    }

    #[test]
    fn undo_without_removed_station_shows_notice() {
        let mut app = App::new(Library::in_memory(vec![station(
            "A",
            "http://a",
            "Synthwave",
        )]));

        app.update(Action::UndoRemoveLibrarySelection);

        assert!(app.library.contains("http://a"));
        assert_eq!(notice_text(&app), Some("Nothing left to undo"));
    }

    #[test]
    fn repeated_removals_restore_in_reverse_order() {
        let mut app = App::new(Library::in_memory(vec![
            station("A", "http://a", "Synthwave"),
            station("B", "http://b", "Synthwave"),
            station("C", "http://c", "Synthwave"),
        ]));

        app.selected = 0;
        app.update(Action::RemoveLibrarySelection);
        app.selected = 0;
        app.update(Action::RemoveLibrarySelection);

        assert!(!app.library.contains("http://a"));
        assert!(!app.library.contains("http://b"));
        assert_eq!(app.undo_history.len(), 2);

        app.update(Action::UndoRemoveLibrarySelection);
        assert!(app.library.contains("http://b"));
        assert!(!app.library.contains("http://a"));
        assert_eq!(notice_text(&app), Some("Restored station: B"));

        app.update(Action::UndoRemoveLibrarySelection);
        assert!(app.library.contains("http://a"));
        assert_eq!(notice_text(&app), Some("Restored station: A"));
        assert_eq!(
            app.library
                .stations
                .iter()
                .map(|station| station.name.as_str())
                .collect::<Vec<_>>(),
            vec!["A", "B", "C"]
        );
    }

    #[test]
    fn undo_history_keeps_only_ten_most_recent_removals() {
        let stations = (0..11)
            .map(|idx| {
                station(
                    &format!("Station {idx}"),
                    &format!("http://{idx}"),
                    "Synthwave",
                )
            })
            .collect::<Vec<_>>();
        let mut app = App::new(Library::in_memory(stations));

        for _ in 0..11 {
            app.selected = 0;
            app.update(Action::RemoveLibrarySelection);
        }

        assert_eq!(app.undo_history.len(), 10);

        for _ in 0..10 {
            app.update(Action::UndoRemoveLibrarySelection);
        }

        assert!(!app.library.contains("http://0"));
        assert!(app.library.contains("http://1"));
        assert!(app.library.contains("http://10"));
        assert_eq!(app.undo_history.len(), 0);
    }

    #[test]
    fn next_genre_selects_playing_station_when_visible() {
        let mut app = App::new(Library::in_memory(vec![
            station("Ambient A", "http://ambient-a", "Ambient"),
            station("Synth A", "http://synth-a", "Synthwave"),
            station("Ambient B", "http://ambient-b", "Ambient"),
        ]));
        app.selected_genre_idx = genre_index(&app, "All");
        app.selected = 1;
        app.playing_url = Some("http://ambient-b".to_string());

        app.update(Action::NextGenre);

        assert_eq!(
            app.library.available_genres[app.selected_genre_idx],
            "Ambient"
        );
        assert_eq!(app.visible_stations()[app.selected].url, "http://ambient-b");
    }

    #[test]
    fn prev_genre_selects_playing_station_when_visible() {
        let mut app = App::new(Library::in_memory(vec![
            station("Ambient A", "http://ambient-a", "Ambient"),
            station("Synth A", "http://synth-a", "Synthwave"),
            station("Ambient B", "http://ambient-b", "Ambient"),
        ]));
        app.selected_genre_idx = genre_index(&app, "Synthwave");
        app.selected = 0;
        app.playing_url = Some("http://ambient-b".to_string());

        app.update(Action::PrevGenre);

        assert_eq!(
            app.library.available_genres[app.selected_genre_idx],
            "Ambient"
        );
        assert_eq!(app.visible_stations()[app.selected].url, "http://ambient-b");
    }

    #[test]
    fn genre_switch_falls_back_to_first_station_when_playing_is_absent() {
        let mut app = App::new(Library::in_memory(vec![
            station("Ambient A", "http://ambient-a", "Ambient"),
            station("Synth A", "http://synth-a", "Synthwave"),
            station("Ambient B", "http://ambient-b", "Ambient"),
        ]));
        app.selected_genre_idx = genre_index(&app, "All");
        app.selected = 2;
        app.playing_url = Some("http://synth-a".to_string());

        app.update(Action::NextGenre);

        assert_eq!(
            app.library.available_genres[app.selected_genre_idx],
            "Ambient"
        );
        assert_eq!(app.selected, 0);
        assert_eq!(app.visible_stations()[app.selected].url, "http://ambient-a");
    }
}