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)
This commit is contained in:
2026-05-15 12:12:10 +02:00
parent 0d7673f5fe
commit 9fb818b804
3 changed files with 614 additions and 112 deletions
+2
View File
@@ -1,2 +1,4 @@
/target /target
*.log *.log
CLAUDE.md
.claude/
+609 -112
View File
@@ -3,19 +3,39 @@ use color_eyre::{
Result, Result,
eyre::{WrapErr, eyre}, eyre::{WrapErr, eyre},
}; };
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use radiobrowser::RadioBrowserAPI; use radiobrowser::RadioBrowserAPI;
use ratatui::widgets::{List, ListItem, ListState};
use ratatui::{ use ratatui::{
DefaultTerminal, Frame, DefaultTerminal, Frame,
layout::{Constraint, Layout}, layout::{Constraint, Layout},
style::Stylize, style::{Color, Modifier, Style},
widgets::{Block, Paragraph}, text::{Line, Span},
widgets::{Block, List, ListItem, ListState, Paragraph},
}; };
use std::io; use std::io;
use tokio::process::Child; use tokio::process::Child;
use tokio::sync::mpsc; use tokio::sync::mpsc;
#[allow(clippy::type_complexity)]
struct SpawnFn(Box<dyn Fn(&str) -> io::Result<Child> + 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<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()
}
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum InputMode { pub enum InputMode {
Normal, Normal,
@@ -31,9 +51,12 @@ pub struct App {
search_query: String, search_query: String,
input_mode: InputMode, input_mode: InputMode,
player: Option<Child>, player: Option<Child>,
current_station: Option<StationInfo>,
current_song: Option<String>, current_song: Option<String>,
played_songs: Vec<String>, played_songs: Vec<String>,
song_rx: Option<mpsc::UnboundedReceiver<String>>, song_rx: Option<mpsc::UnboundedReceiver<String>>,
error_message: Option<String>,
spawn_player: Option<SpawnFn>,
} }
impl Default for App { impl Default for App {
@@ -50,13 +73,23 @@ impl Default for App {
input_mode: InputMode::Normal, input_mode: InputMode::Normal,
search_query: String::new(), search_query: String::new(),
player: None, player: None,
current_station: None,
current_song: None, current_song: None,
played_songs: Vec::new(), played_songs: Vec::new(),
song_rx: None, 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 { impl App {
pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.exit { while !self.exit {
@@ -69,74 +102,167 @@ impl App {
Ok(()) Ok(())
} }
fn draw(&self, frame: &mut Frame) { fn draw(&mut self, frame: &mut Frame) {
let area = frame.area(); let area = frame.area();
let [main, footer] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
let [left, right] = let [left, right] =
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]) Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.areas(area); .areas(main);
let [top_right, bottom_right] =
Layout::vertical([Constraint::Percentage(10), Constraint::Percentage(90)]).areas(right);
let [top_left, bottom_left] = 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 [top_right, bottom_right] =
let max_name_len = bottom_left.width.saturating_sub(4) as usize; Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(right);
// ── Info / now-playing panel ───────────────────────────────────────
let info_content: Vec<Line> = {
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<ListItem> = self let station_items: Vec<ListItem> = self
.filtered_stations .filtered_stations
.iter() .iter()
.map(|s| { .map(|s| {
let name = if s.name.len() > max_name_len { let codec = if s.codec.is_empty() { "?".to_string() } else { s.codec.clone() };
let truncated: String = s let country = if s.country_code.is_empty() { "--".to_string() } else { s.country_code.clone() };
.name let meta = format!(" {country} {codec} {}k", s.bitrate);
.chars() let name_max = available_width.saturating_sub(meta.len());
.take(max_name_len.saturating_sub(3)) let name: String = if s.name.trim().len() > name_max {
.collect(); format!(
format!("{}...", truncated) "{}...",
s.name.trim().chars().take(name_max.saturating_sub(3)).collect::<String>()
)
} else { } 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(); .collect();
let stations_list = List::new(station_items) let stations_list = List::new(station_items)
.block(Block::bordered().title("Station Selection")) .block(Block::bordered().title("Stations"))
.highlight_symbol(">>") .highlight_symbol(">>")
.highlight_style(ratatui::style::Style::default().reversed()); .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_stateful_widget( frame.render_stateful_widget(stations_list, bottom_left, &mut self.station_list_state);
stations_list,
bottom_left, // ── Search box ─────────────────────────────────────────────────────
&mut self.station_list_state.clone(), 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 // ── Played songs ───────────────────────────────────────────────────
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
let song_items: Vec<ListItem> = self let song_items: Vec<ListItem> = self
.played_songs .played_songs
.iter() .iter()
.map(|s| ListItem::new(s.clone())) .map(|s| ListItem::new(s.clone()))
.collect(); .collect();
let songs_list = List::new(song_items).block(Block::bordered().title("Played Songs")); frame.render_widget(
frame.render_widget(songs_list, bottom_right); 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<()> { async fn handle_events(&mut self) -> io::Result<()> {
@@ -153,25 +279,30 @@ impl App {
fn check_song_updates(&mut self) { fn check_song_updates(&mut self) {
let mut new_songs = Vec::new(); let mut new_songs = Vec::new();
if let Some(rx) = &mut self.song_rx { if let Some(rx) = &mut self.song_rx {
while let Ok(song) = rx.try_recv() { while let Ok(song) = rx.try_recv() {
new_songs.push(song); new_songs.push(song);
} }
} }
for song in new_songs { for song in new_songs {
self.update_current_song(song); 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) { fn update_current_song(&mut self, song: String) {
if let Some(prev) = &self.current_song { if let Some(prev) = &self.current_song
if prev != &song { && prev != &song
self.played_songs.insert(0, prev.clone()); {
if self.played_songs.len() > 20 { self.played_songs.insert(0, prev.clone());
self.played_songs.pop(); if self.played_songs.len() > 20 {
} self.played_songs.pop();
} }
} }
self.current_song = Some(song); self.current_song = Some(song);
@@ -185,31 +316,46 @@ impl App {
self.search_query.clear(); self.search_query.clear();
} }
KeyCode::Char('q') => self.exit().await, 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') => { KeyCode::Down | KeyCode::Char('j') => {
if let Some(i) = self.station_list_state.selected() { if let Some(i) = self.station_list_state.selected()
if !self.filtered_stations.is_empty() { && !self.filtered_stations.is_empty()
let next = (i + 1) % self.filtered_stations.len(); {
self.station_list_state.select(Some(next)); let next = (i + 1) % self.filtered_stations.len();
} self.station_list_state.select(Some(next));
} }
} }
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
if let Some(i) = self.station_list_state.selected() { if let Some(i) = self.station_list_state.selected()
if !self.filtered_stations.is_empty() { && !self.filtered_stations.is_empty()
let prev = if i == 0 { {
self.filtered_stations.len() - 1 let prev = if i == 0 { self.filtered_stations.len() - 1 } else { i - 1 };
} else { self.station_list_state.select(Some(prev));
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 => { KeyCode::Enter => {
if let Some(i) = self.station_list_state.selected() { if let Some(i) = self.station_list_state.selected()
if let Some(station) = self.filtered_stations.get(i) { && let Some(station) = self.filtered_stations.get(i).cloned()
self.play_station(station.url.clone()).await; {
} self.play_station(station).await;
} }
} }
_ => {} _ => {}
@@ -223,7 +369,12 @@ impl App {
self.search_query.pop(); self.search_query.pop();
self.update_filter(); 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; self.input_mode = InputMode::Normal;
} }
_ => {} _ => {}
@@ -242,28 +393,34 @@ impl App {
let api = RadioBrowserAPI::new() let api = RadioBrowserAPI::new()
.await .await
.map_err(|e| eyre!("Failed to create RadioBrowserAPI: {e}"))?; .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() { let (de, us, at) = tokio::try_join!(
self.stations.push(StationInfo { async { api.get_stations().country("Germany").send().await.map_err(|e| eyre!("Failed to fetch DE stations: {e}")) },
name: "No stations found".into(), async { api.get_stations().country("United States").send().await.map_err(|e| eyre!("Failed to fetch US stations: {e}")) },
url: "".into(), 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<StationInfo> = de
.into_iter() .into_iter()
.map(|s: radiobrowser::ApiStation| StationInfo { .chain(us)
name: s.name, .chain(at)
url: s.url, .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(); .collect();
stations.sort_by_key(|a| a.name.to_lowercase());
self.stations = stations;
self.update_filter(); self.update_filter();
Ok(()) 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() { if let Some(mut child) = self.player.take() {
let _ = child.kill().await; 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(); let (tx, rx) = mpsc::unbounded_channel();
self.song_rx = Some(rx); self.song_rx = Some(rx);
let mut child = tokio::process::Command::new("mpv") match spawn(&station.url) {
.arg(&url) Err(e) => {
.arg("--no-video") self.error_message = Some(format!("Failed to start mpv: {e}"));
.arg("--quiet") self.current_station = None;
.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);
}
} }
}); Ok(mut child) => {
let mut stdout = child.stdout.take().expect("mpv stdout");
self.player = Some(child); 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<StationInfo> {
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);
} }
} }
+3
View File
@@ -2,4 +2,7 @@
pub struct StationInfo { pub struct StationInfo {
pub name: String, pub name: String,
pub url: String, pub url: String,
pub country_code: String,
pub bitrate: u32,
pub codec: String,
} }