From 9fb818b8040e571d34c87775a3226f9354834ecc Mon Sep 17 00:00:00 2001 From: mace Date: Fri, 15 May 2026 12:12:10 +0200 Subject: [PATCH] Added tests, improve UI and controls - Ctrl+U / Ctrl+D for half-page scrolling in the station list - ESC in Normal mode clears the active search filter - Replace dual #[cfg(test)] play_station methods with a single method using an injected SpawnFn; tests use App::without_player() - Fix all clippy warnings (collapsible ifs, sort_by_key, type_complexity) --- .gitignore | 2 + src/radio/app.rs | 721 ++++++++++++++++++++++++++++++++++++------- src/radio/station.rs | 3 + 3 files changed, 614 insertions(+), 112 deletions(-) diff --git a/.gitignore b/.gitignore index 1a4595e..8c7f108 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target *.log +CLAUDE.md +.claude/ diff --git a/src/radio/app.rs b/src/radio/app.rs index 368b7ee..668168c 100644 --- a/src/radio/app.rs +++ b/src/radio/app.rs @@ -3,19 +3,39 @@ use color_eyre::{ Result, eyre::{WrapErr, eyre}, }; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use radiobrowser::RadioBrowserAPI; -use ratatui::widgets::{List, ListItem, ListState}; use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Layout}, - style::Stylize, - widgets::{Block, Paragraph}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, List, ListItem, ListState, Paragraph}, }; use std::io; use tokio::process::Child; use tokio::sync::mpsc; +#[allow(clippy::type_complexity)] +struct SpawnFn(Box io::Result + Send + Sync>); + +impl std::fmt::Debug for SpawnFn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SpawnFn") + } +} + +fn mpv_spawn(url: &str) -> io::Result { + tokio::process::Command::new("mpv") + .arg(url) + .arg("--no-video") + .arg("--quiet") + .arg("--term-playing-msg=${media-title}") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() +} + #[derive(Debug, PartialEq)] pub enum InputMode { Normal, @@ -31,9 +51,12 @@ pub struct App { search_query: String, input_mode: InputMode, player: Option, + current_station: Option, current_song: Option, played_songs: Vec, song_rx: Option>, + error_message: Option, + spawn_player: Option, } impl Default for App { @@ -50,13 +73,23 @@ impl Default for App { input_mode: InputMode::Normal, search_query: String::new(), player: None, + current_station: None, current_song: None, played_songs: Vec::new(), song_rx: None, + error_message: None, + spawn_player: Some(SpawnFn(Box::new(mpv_spawn))), } } } +impl App { + pub fn without_player(mut self) -> Self { + self.spawn_player = None; + self + } +} + impl App { pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { while !self.exit { @@ -69,74 +102,167 @@ impl App { Ok(()) } - fn draw(&self, frame: &mut Frame) { + fn draw(&mut self, frame: &mut Frame) { let area = frame.area(); + let [main, footer] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + let [left, right] = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]) - .areas(area); - - let [top_right, bottom_right] = - Layout::vertical([Constraint::Percentage(10), Constraint::Percentage(90)]).areas(right); + .areas(main); let [top_left, bottom_left] = - Layout::vertical([Constraint::Percentage(10), Constraint::Percentage(90)]).areas(left); + Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).areas(left); - // Station list - let max_name_len = bottom_left.width.saturating_sub(4) as usize; + let [top_right, bottom_right] = + Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(right); + + // ── Info / now-playing panel ─────────────────────────────────────── + let info_content: Vec = { + let song_line = match &self.current_song { + Some(song) => Line::from(vec![ + Span::styled("▶ ", Style::default().fg(Color::Green)), + Span::styled( + song.clone(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + ]), + None => Line::from(Span::styled( + "No station playing", + Style::default().add_modifier(Modifier::DIM), + )), + }; + + let station = self + .current_station + .as_ref() + .or_else(|| self.station_list_state.selected().and_then(|i| self.filtered_stations.get(i))); + + if let Some(s) = station { + let name_line = Line::from(Span::styled( + s.name.clone(), + Style::default().add_modifier(Modifier::BOLD), + )); + let codec = if s.codec.is_empty() { "?".into() } else { s.codec.to_uppercase() }; + let country = if s.country_code.is_empty() { "--".into() } else { s.country_code.clone() }; + let meta_line = Line::from(Span::styled( + format!("{country} {codec} {}kbps", s.bitrate), + Style::default().add_modifier(Modifier::DIM), + )); + let error_line = if let Some(err) = &self.error_message { + Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red))) + } else { + Line::from("") + }; + vec![song_line, name_line, meta_line, error_line] + } else { + let error_line = if let Some(err) = &self.error_message { + Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red))) + } else { + Line::from("") + }; + vec![song_line, error_line] + } + }; + + let info_block = Block::bordered() + .title("Info") + .border_style(Style::default().fg(Color::Cyan)); + frame.render_widget(Paragraph::new(info_content).block(info_block), top_right); + + // ── Station list ─────────────────────────────────────────────────── + let available_width = bottom_left.width.saturating_sub(4) as usize; let station_items: Vec = self .filtered_stations .iter() .map(|s| { - let name = if s.name.len() > max_name_len { - let truncated: String = s - .name - .chars() - .take(max_name_len.saturating_sub(3)) - .collect(); - format!("{}...", truncated) + let codec = if s.codec.is_empty() { "?".to_string() } else { s.codec.clone() }; + let country = if s.country_code.is_empty() { "--".to_string() } else { s.country_code.clone() }; + let meta = format!(" {country} {codec} {}k", s.bitrate); + let name_max = available_width.saturating_sub(meta.len()); + let name: String = if s.name.trim().len() > name_max { + format!( + "{}...", + s.name.trim().chars().take(name_max.saturating_sub(3)).collect::() + ) } else { - s.name.clone() + s.name.trim().to_owned() }; - ListItem::new(name.trim().to_owned()) + + let is_playing = self + .current_station + .as_ref() + .map(|cs| cs.url == s.url) + .unwrap_or(false); + + let name_style = if is_playing { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + ListItem::new(Line::from(vec![ + Span::styled(name, name_style), + Span::styled(meta, Style::default().add_modifier(Modifier::DIM)), + ])) }) .collect(); let stations_list = List::new(station_items) - .block(Block::bordered().title("Station Selection")) + .block(Block::bordered().title("Stations")) .highlight_symbol(">>") - .highlight_style(ratatui::style::Style::default().reversed()); + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - frame.render_stateful_widget( - stations_list, - bottom_left, - &mut self.station_list_state.clone(), + frame.render_stateful_widget(stations_list, bottom_left, &mut self.station_list_state); + + // ── Search box ───────────────────────────────────────────────────── + let mut search_text = self.search_query.clone(); + let search_border_style = if self.input_mode == InputMode::Search { + search_text.push('█'); + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + frame.render_widget( + Paragraph::new(search_text).block( + Block::bordered() + .title("Search [/]") + .border_style(search_border_style), + ), + top_left, ); - // Info: show current song - let info_text = match &self.current_song { - Some(song) => format!("Now playing: {}", song), - None => "No song playing".to_string(), - }; - let paragraph = Paragraph::new(info_text).block(Block::bordered().title("Info")); - frame.render_widget(paragraph, top_left); - - // Search box - let mut search_string = self.search_query.clone(); - if self.input_mode == InputMode::Search { - search_string.push('█'); - } - let search = Paragraph::new(search_string).block(Block::bordered().title("Search")); - frame.render_widget(search, top_right); - - // Played Songs list + // ── Played songs ─────────────────────────────────────────────────── let song_items: Vec = self .played_songs .iter() .map(|s| ListItem::new(s.clone())) .collect(); - let songs_list = List::new(song_items).block(Block::bordered().title("Played Songs")); - frame.render_widget(songs_list, bottom_right); + frame.render_widget( + List::new(song_items).block(Block::bordered().title("Played Songs")), + bottom_right, + ); + + // ── Footer key hints ─────────────────────────────────────────────── + let hints = if self.input_mode == InputMode::Search { + Line::from(vec![ + key_hint(" Esc "), Span::raw(" cancel "), + key_hint(" Enter "), Span::raw(" confirm"), + ]) + } else { + Line::from(vec![ + key_hint(" / "), Span::raw(" search "), + key_hint(" Esc "), Span::raw(" clear search "), + key_hint(" j/k "), Span::raw(" navigate "), + key_hint(" ^U/^D "), Span::raw(" half page "), + key_hint(" Enter "), Span::raw(" play "), + key_hint(" q "), Span::raw(" quit"), + ]) + }; + frame.render_widget(Paragraph::new(hints), footer); } async fn handle_events(&mut self) -> io::Result<()> { @@ -153,25 +279,30 @@ impl App { fn check_song_updates(&mut self) { let mut new_songs = Vec::new(); - if let Some(rx) = &mut self.song_rx { while let Ok(song) = rx.try_recv() { new_songs.push(song); } } - for song in new_songs { self.update_current_song(song); } + + if let Some(child) = &mut self.player + && let Ok(Some(_)) = child.try_wait() + { + self.player = None; + self.error_message = Some("Stream disconnected".to_string()); + } } fn update_current_song(&mut self, song: String) { - if let Some(prev) = &self.current_song { - if prev != &song { - self.played_songs.insert(0, prev.clone()); - if self.played_songs.len() > 20 { - self.played_songs.pop(); - } + if let Some(prev) = &self.current_song + && prev != &song + { + self.played_songs.insert(0, prev.clone()); + if self.played_songs.len() > 20 { + self.played_songs.pop(); } } self.current_song = Some(song); @@ -185,31 +316,46 @@ impl App { self.search_query.clear(); } KeyCode::Char('q') => self.exit().await, + KeyCode::Esc if !self.search_query.is_empty() => { + self.search_query.clear(); + self.update_filter(); + } KeyCode::Down | KeyCode::Char('j') => { - if let Some(i) = self.station_list_state.selected() { - if !self.filtered_stations.is_empty() { - let next = (i + 1) % self.filtered_stations.len(); - self.station_list_state.select(Some(next)); - } + if let Some(i) = self.station_list_state.selected() + && !self.filtered_stations.is_empty() + { + let next = (i + 1) % self.filtered_stations.len(); + self.station_list_state.select(Some(next)); } } KeyCode::Up | KeyCode::Char('k') => { - if let Some(i) = self.station_list_state.selected() { - if !self.filtered_stations.is_empty() { - let prev = if i == 0 { - self.filtered_stations.len() - 1 - } else { - i - 1 - }; - self.station_list_state.select(Some(prev)); - } + if let Some(i) = self.station_list_state.selected() + && !self.filtered_stations.is_empty() + { + let prev = if i == 0 { self.filtered_stations.len() - 1 } else { i - 1 }; + self.station_list_state.select(Some(prev)); + } + } + KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(i) = self.station_list_state.selected() + && !self.filtered_stations.is_empty() + { + let next = (i + 10).min(self.filtered_stations.len() - 1); + self.station_list_state.select(Some(next)); + } + } + KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(i) = self.station_list_state.selected() + && !self.filtered_stations.is_empty() + { + self.station_list_state.select(Some(i.saturating_sub(10))); } } KeyCode::Enter => { - if let Some(i) = self.station_list_state.selected() { - if let Some(station) = self.filtered_stations.get(i) { - self.play_station(station.url.clone()).await; - } + if let Some(i) = self.station_list_state.selected() + && let Some(station) = self.filtered_stations.get(i).cloned() + { + self.play_station(station).await; } } _ => {} @@ -223,7 +369,12 @@ impl App { self.search_query.pop(); self.update_filter(); } - KeyCode::Esc | KeyCode::Enter => { + KeyCode::Esc => { + self.search_query.clear(); + self.update_filter(); + self.input_mode = InputMode::Normal; + } + KeyCode::Enter => { self.input_mode = InputMode::Normal; } _ => {} @@ -242,28 +393,34 @@ impl App { let api = RadioBrowserAPI::new() .await .map_err(|e| eyre!("Failed to create RadioBrowserAPI: {e}"))?; - let stations = api - .get_stations() - .country("Germany") - .send() - .await - .map_err(|e| eyre!("Failed to fetch station: {e}"))?; - if self.stations.is_empty() { - self.stations.push(StationInfo { - name: "No stations found".into(), - url: "".into(), - }); - } + let (de, us, at) = tokio::try_join!( + async { api.get_stations().country("Germany").send().await.map_err(|e| eyre!("Failed to fetch DE stations: {e}")) }, + async { api.get_stations().country("United States").send().await.map_err(|e| eyre!("Failed to fetch US stations: {e}")) }, + async { api.get_stations().country("Austria").send().await.map_err(|e| eyre!("Failed to fetch AT stations: {e}")) }, + )?; - self.stations = stations + let mut seen_urls = std::collections::HashSet::new(); + let mut stations: Vec = de .into_iter() - .map(|s: radiobrowser::ApiStation| StationInfo { - name: s.name, - url: s.url, + .chain(us) + .chain(at) + .filter_map(|s: radiobrowser::ApiStation| { + if s.url.is_empty() || !seen_urls.insert(s.url.clone()) { + return None; + } + Some(StationInfo { + name: s.name, + url: s.url, + country_code: s.countrycode, + bitrate: s.bitrate, + codec: s.codec, + }) }) .collect(); + stations.sort_by_key(|a| a.name.to_lowercase()); + self.stations = stations; self.update_filter(); Ok(()) @@ -289,36 +446,376 @@ impl App { } } - async fn play_station(&mut self, url: String) { + async fn play_station(&mut self, station: StationInfo) { if let Some(mut child) = self.player.take() { let _ = child.kill().await; } + self.error_message = None; + self.current_song = None; + self.current_station = Some(station.clone()); + + let Some(SpawnFn(ref spawn)) = self.spawn_player else { return }; + let (tx, rx) = mpsc::unbounded_channel(); self.song_rx = Some(rx); - let mut child = tokio::process::Command::new("mpv") - .arg(&url) - .arg("--no-video") - .arg("--quiet") - .arg("--term-playing-msg=${media-title}") - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .expect("Failed to spawn mpv"); - - let mut stdout = child.stdout.take().expect("No stdout"); - tokio::spawn(async move { - use tokio::io::{AsyncBufReadExt, BufReader}; - let mut reader = BufReader::new(&mut stdout).lines(); - while let Ok(Some(line)) = reader.next_line().await { - let title = line.trim().to_string(); - if !title.is_empty() { - let _ = tx.send(title); - } + match spawn(&station.url) { + Err(e) => { + self.error_message = Some(format!("Failed to start mpv: {e}")); + self.current_station = None; } - }); - - self.player = Some(child); + Ok(mut child) => { + let mut stdout = child.stdout.take().expect("mpv stdout"); + tokio::spawn(async move { + use tokio::io::{AsyncBufReadExt, BufReader}; + let mut reader = BufReader::new(&mut stdout).lines(); + while let Ok(Some(line)) = reader.next_line().await { + let title = line.trim().to_string(); + if !title.is_empty() { + let _ = tx.send(title); + } + } + }); + self.player = Some(child); + } + } + } +} + +fn key_hint(label: &str) -> Span<'_> { + Span::styled( + label, + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyModifiers; + + fn make_stations(names: &[&str]) -> Vec { + names + .iter() + .enumerate() + .map(|(i, n)| StationInfo { + name: n.to_string(), + url: format!("http://station{i}.test"), + country_code: "DE".to_string(), + bitrate: 128, + codec: "MP3".to_string(), + }) + .collect() + } + + fn app_with(names: &[&str]) -> App { + let mut app = App::default().without_player(); + let stations = make_stations(names); + app.stations = stations.clone(); + app.filtered_stations = stations; + app + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn ctrl(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) + } + + // --- navigation --- + + #[tokio::test] + async fn down_advances_selection() { + let mut app = app_with(&["A", "B", "C"]); + app.handle_key_event(key(KeyCode::Down)).await; + assert_eq!(app.station_list_state.selected(), Some(1)); + } + + #[tokio::test] + async fn j_advances_selection() { + let mut app = app_with(&["A", "B", "C"]); + app.handle_key_event(key(KeyCode::Char('j'))).await; + assert_eq!(app.station_list_state.selected(), Some(1)); + } + + #[tokio::test] + async fn down_wraps_at_bottom() { + let mut app = app_with(&["A", "B", "C"]); + app.station_list_state.select(Some(2)); + app.handle_key_event(key(KeyCode::Down)).await; + assert_eq!(app.station_list_state.selected(), Some(0)); + } + + #[tokio::test] + async fn up_wraps_at_top() { + let mut app = app_with(&["A", "B", "C"]); + app.handle_key_event(key(KeyCode::Up)).await; + assert_eq!(app.station_list_state.selected(), Some(2)); + } + + #[tokio::test] + async fn k_wraps_at_top() { + let mut app = app_with(&["A", "B", "C"]); + app.handle_key_event(key(KeyCode::Char('k'))).await; + assert_eq!(app.station_list_state.selected(), Some(2)); + } + + #[tokio::test] + async fn navigation_no_panic_on_empty_list() { + let mut app = App::default(); + app.handle_key_event(key(KeyCode::Char('j'))).await; + app.handle_key_event(key(KeyCode::Char('k'))).await; + assert_eq!(app.station_list_state.selected(), Some(0)); + } + + // --- half-page scroll --- + + #[tokio::test] + async fn ctrl_d_scrolls_down_half_page() { + let names: Vec<&str> = (0..25).map(|_| "X").collect(); + let mut app = app_with(&names); + app.station_list_state.select(Some(0)); + app.handle_key_event(ctrl(KeyCode::Char('d'))).await; + assert_eq!(app.station_list_state.selected(), Some(10)); + } + + #[tokio::test] + async fn ctrl_d_clamps_at_end() { + let names: Vec<&str> = (0..5).map(|_| "X").collect(); + let mut app = app_with(&names); + app.station_list_state.select(Some(3)); + app.handle_key_event(ctrl(KeyCode::Char('d'))).await; + assert_eq!(app.station_list_state.selected(), Some(4)); + } + + #[tokio::test] + async fn ctrl_u_scrolls_up_half_page() { + let names: Vec<&str> = (0..25).map(|_| "X").collect(); + let mut app = app_with(&names); + app.station_list_state.select(Some(20)); + app.handle_key_event(ctrl(KeyCode::Char('u'))).await; + assert_eq!(app.station_list_state.selected(), Some(10)); + } + + #[tokio::test] + async fn ctrl_u_clamps_at_start() { + let names: Vec<&str> = (0..5).map(|_| "X").collect(); + let mut app = app_with(&names); + app.station_list_state.select(Some(2)); + app.handle_key_event(ctrl(KeyCode::Char('u'))).await; + assert_eq!(app.station_list_state.selected(), Some(0)); + } + + #[tokio::test] + async fn esc_in_normal_mode_clears_search() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.search_query = "rock".to_string(); + app.update_filter(); + assert_eq!(app.filtered_stations.len(), 1); + app.handle_key_event(key(KeyCode::Esc)).await; + assert!(app.search_query.is_empty()); + assert_eq!(app.filtered_stations.len(), 2); + } + + #[tokio::test] + async fn esc_in_normal_mode_no_op_when_no_search() { + let mut app = app_with(&["Rock FM"]); + app.handle_key_event(key(KeyCode::Esc)).await; + assert_eq!(app.input_mode, InputMode::Normal); + assert_eq!(app.filtered_stations.len(), 1); + } + + // --- mode switching --- + + #[tokio::test] + async fn slash_enters_search_mode() { + let mut app = App::default(); + app.handle_key_event(key(KeyCode::Char('/'))).await; + assert_eq!(app.input_mode, InputMode::Search); + assert!(app.search_query.is_empty()); + } + + #[tokio::test] + async fn esc_exits_search_and_clears_query() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.input_mode = InputMode::Search; + // 'k' only appears in "Rock FM", not in "Jazz Radio" + app.handle_key_event(key(KeyCode::Char('k'))).await; + assert_eq!(app.filtered_stations.len(), 1); + app.handle_key_event(key(KeyCode::Esc)).await; + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.search_query.is_empty()); + assert_eq!(app.filtered_stations.len(), 2); // list restored + } + + #[tokio::test] + async fn enter_exits_search_keeps_filter() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.input_mode = InputMode::Search; + // 'k' only appears in "Rock FM", not in "Jazz Radio" + app.handle_key_event(key(KeyCode::Char('k'))).await; + app.handle_key_event(key(KeyCode::Enter)).await; + assert_eq!(app.input_mode, InputMode::Normal); + assert_eq!(app.filtered_stations.len(), 1); // filter stays active + } + + #[tokio::test] + async fn q_sets_exit_flag() { + let mut app = App::default(); + app.handle_key_event(key(KeyCode::Char('q'))).await; + assert!(app.exit); + } + + // --- search filtering --- + + #[tokio::test] + async fn search_typing_filters_by_name() { + let mut app = app_with(&["Rock FM", "Jazz Radio", "Pop Station"]); + app.input_mode = InputMode::Search; + app.handle_key_event(key(KeyCode::Char('j'))).await; + assert_eq!(app.filtered_stations.len(), 1); + assert_eq!(app.filtered_stations[0].name, "Jazz Radio"); + } + + #[tokio::test] + async fn search_is_case_insensitive() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.input_mode = InputMode::Search; + for c in "ROCK".chars() { + app.handle_key_event(key(KeyCode::Char(c))).await; + } + assert_eq!(app.filtered_stations.len(), 1); + assert_eq!(app.filtered_stations[0].name, "Rock FM"); + } + + #[tokio::test] + async fn search_backspace_removes_char_and_updates_list() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.input_mode = InputMode::Search; + app.handle_key_event(key(KeyCode::Char('j'))).await; + assert_eq!(app.filtered_stations.len(), 1); + app.handle_key_event(key(KeyCode::Backspace)).await; + assert!(app.search_query.is_empty()); + assert_eq!(app.filtered_stations.len(), 2); + } + + #[tokio::test] + async fn no_match_clears_selection() { + let mut app = app_with(&["Rock FM"]); + app.input_mode = InputMode::Search; + app.handle_key_event(key(KeyCode::Char('z'))).await; + assert!(app.filtered_stations.is_empty()); + assert_eq!(app.station_list_state.selected(), None); + } + + #[tokio::test] + async fn clearing_search_restores_full_list() { + let mut app = app_with(&["A", "B", "C"]); + app.search_query = "x".to_string(); + app.update_filter(); + assert!(app.filtered_stations.is_empty()); + app.search_query.clear(); + app.update_filter(); + assert_eq!(app.filtered_stations.len(), 3); + } + + // --- play_station (test stub) --- + + #[tokio::test] + async fn enter_plays_selected_station() { + let mut app = app_with(&["Rock FM", "Jazz Radio"]); + app.handle_key_event(key(KeyCode::Enter)).await; + assert_eq!(app.current_station.as_ref().map(|s| s.name.as_str()), Some("Rock FM")); + } + + #[tokio::test] + async fn enter_clears_error_on_new_play() { + let mut app = app_with(&["Rock FM"]); + app.error_message = Some("old error".to_string()); + app.handle_key_event(key(KeyCode::Enter)).await; + assert!(app.error_message.is_none()); + } + + // --- song tracking --- + + #[test] + fn first_song_sets_current_with_no_history() { + let mut app = App::default(); + app.update_current_song("Song A".to_string()); + assert_eq!(app.current_song, Some("Song A".to_string())); + assert!(app.played_songs.is_empty()); + } + + #[test] + fn new_song_moves_previous_to_front_of_history() { + let mut app = App::default(); + app.update_current_song("Song A".to_string()); + app.update_current_song("Song B".to_string()); + assert_eq!(app.current_song, Some("Song B".to_string())); + assert_eq!(app.played_songs, vec!["Song A"]); + } + + #[test] + fn same_song_not_added_to_history() { + let mut app = App::default(); + app.update_current_song("Song A".to_string()); + app.update_current_song("Song A".to_string()); + assert!(app.played_songs.is_empty()); + } + + #[test] + fn history_is_newest_first() { + let mut app = App::default(); + app.update_current_song("Song 1".to_string()); + app.update_current_song("Song 2".to_string()); + app.update_current_song("Song 3".to_string()); + assert_eq!(app.played_songs[0], "Song 2"); + assert_eq!(app.played_songs[1], "Song 1"); + } + + #[test] + fn played_songs_capped_at_20() { + let mut app = App::default(); + for i in 0..=21 { + app.update_current_song(format!("Song {i}")); + } + assert_eq!(app.played_songs.len(), 20); + } + + // --- channel-based song updates --- + + #[test] + fn check_song_updates_reads_from_channel() { + let mut app = App::default(); + let (tx, rx) = mpsc::unbounded_channel(); + app.song_rx = Some(rx); + tx.send("Live Song".to_string()).unwrap(); + app.check_song_updates(); + assert_eq!(app.current_song, Some("Live Song".to_string())); + } + + #[test] + fn check_song_updates_processes_multiple_messages() { + let mut app = App::default(); + let (tx, rx) = mpsc::unbounded_channel(); + app.song_rx = Some(rx); + tx.send("Song X".to_string()).unwrap(); + tx.send("Song Y".to_string()).unwrap(); + app.check_song_updates(); + assert_eq!(app.current_song, Some("Song Y".to_string())); + assert_eq!(app.played_songs[0], "Song X"); + } + + #[test] + fn check_song_updates_no_panic_without_channel() { + let mut app = App::default(); + app.check_song_updates(); + assert_eq!(app.current_song, None); } } diff --git a/src/radio/station.rs b/src/radio/station.rs index 3504abc..e44f809 100644 --- a/src/radio/station.rs +++ b/src/radio/station.rs @@ -2,4 +2,7 @@ pub struct StationInfo { pub name: String, pub url: String, + pub country_code: String, + pub bitrate: u32, + pub codec: String, }