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:
@@ -1,2 +1,4 @@
|
||||
/target
|
||||
*.log
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
+609
-112
@@ -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<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)]
|
||||
pub enum InputMode {
|
||||
Normal,
|
||||
@@ -31,9 +51,12 @@ pub struct App {
|
||||
search_query: String,
|
||||
input_mode: InputMode,
|
||||
player: Option<Child>,
|
||||
current_station: Option<StationInfo>,
|
||||
current_song: Option<String>,
|
||||
played_songs: Vec<String>,
|
||||
song_rx: Option<mpsc::UnboundedReceiver<String>>,
|
||||
error_message: Option<String>,
|
||||
spawn_player: Option<SpawnFn>,
|
||||
}
|
||||
|
||||
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<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
|
||||
.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::<String>()
|
||||
)
|
||||
} 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<ListItem> = 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<StationInfo> = 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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
pub struct StationInfo {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub country_code: String,
|
||||
pub bitrate: u32,
|
||||
pub codec: String,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user