From 95ce4f235296759a3d5907e8eed3f198eb518bcc Mon Sep 17 00:00:00 2001 From: talwat <83217276+talwat@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:50:38 +0100 Subject: [PATCH] chore: add more tests --- src/download.rs | 2 +- src/player.rs | 3 +- src/tests.rs | 1 - src/tests/tracks.rs | 42 +++++---- src/tests/ui.rs | 222 +++++++++++++++++++++++++++++++------------- src/tests/volume.rs | 28 ------ src/tracks/list.rs | 6 +- src/ui.rs | 8 +- src/ui/interface.rs | 17 ++-- src/ui/window.rs | 36 +++++-- 10 files changed, 228 insertions(+), 137 deletions(-) delete mode 100644 src/tests/volume.rs diff --git a/src/download.rs b/src/download.rs index e1da2fa..83fda8f 100644 --- a/src/download.rs +++ b/src/download.rs @@ -12,7 +12,7 @@ use tokio::{ use crate::tracks; static LOADING: AtomicBool = AtomicBool::new(false); -static PROGRESS: AtomicU8 = AtomicU8::new(0); +pub(crate) static PROGRESS: AtomicU8 = AtomicU8::new(0); pub type Progress = &'static AtomicU8; pub struct Downloader { diff --git a/src/player.rs b/src/player.rs index 41be4c1..7002f07 100644 --- a/src/player.rs +++ b/src/player.rs @@ -90,7 +90,8 @@ impl Player { let current = Current::Loading(None); let list = List::load(args.track_list.as_ref()).await?; - let state = ui::State::initial(sink.clone(), &args, current.clone(), list.name.clone()); + let state = + ui::State::initial(sink.clone(), args.width, current.clone(), list.name.clone()); let volume = PersistentVolume::load().await?; sink.set_volume(volume.float()); diff --git a/src/tests.rs b/src/tests.rs index f85d159..044d5f2 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2,4 +2,3 @@ mod bookmark; mod player; mod tracks; mod ui; -mod volume; diff --git a/src/tests/tracks.rs b/src/tests/tracks.rs index ef93b05..b6de65c 100644 --- a/src/tests/tracks.rs +++ b/src/tests/tracks.rs @@ -89,7 +89,7 @@ mod info { } #[test] - fn info_width_counts_graphemes() { + fn width_counts_graphemes() { // We cannot create a valid decoder for arbitrary bytes here, so test width through constructor logic directly. let display = "a̐é"; // multiple-grapheme clusters let width = display.graphemes(true).count(); @@ -126,17 +126,18 @@ mod decoded { #[cfg(test)] mod list { - use crate::tracks::List; + use crate::{download::PROGRESS, tracks::List}; + use reqwest::Client; #[test] - fn list_base_works() { + fn base_works() { let text = "http://base/\ntrack1\ntrack2"; let list = List::new("test", text, None); - assert_eq!(list.base(), "http://base/"); + assert_eq!(list.header(), "http://base/"); } #[test] - fn list_random_path_parses_custom_display() { + fn random_path_parses_custom_display() { let text = "http://x/\npath!Display"; let list = List::new("t", text, None); @@ -146,7 +147,7 @@ mod list { } #[test] - fn list_random_path_no_display() { + fn random_path_no_display() { let text = "http://x/\ntrackA"; let list = List::new("t", text, None); @@ -160,21 +161,13 @@ mod list { let text = "base\na \nb "; let list = List::new("name", text, None); - assert_eq!(list.base(), "base"); + assert_eq!(list.header(), "base"); assert_eq!(list.lines[1], "a"); assert_eq!(list.lines[2], "b"); } #[test] - fn list_noheader_base() { - let text = "noheader\nhttps://example.com/track.mp3"; - let list = List::new("test", text, None); - // noheader means the first line should be treated as base - assert_eq!(list.base(), "noheader"); - } - - #[test] - fn list_custom_display_with_exclamation() { + fn custom_display_with_exclamation() { let text = "http://base/\nfile.mp3!My Custom Name"; let list = List::new("t", text, None); let (path, display) = list.random_path(); @@ -183,10 +176,25 @@ mod list { } #[test] - fn list_single_track() { + fn single_track() { let text = "base\nonly_track.mp3"; let list = List::new("name", text, None); let (path, _) = list.random_path(); assert_eq!(path, "only_track.mp3"); } + + #[tokio::test] + async fn download() { + let text = "https://stream.chillhop.com/mp3/\n9476!Apple Juice"; + let list = List::new("name", text, None); + + let client = Client::new(); + let track = list.random(&client, &PROGRESS).await.unwrap(); + assert_eq!(track.display, "Apple Juice"); + assert_eq!(track.path, "https://stream.chillhop.com/mp3/9476"); + assert_eq!(track.data.len(), 3150424); + + let decoded = track.decode().unwrap(); + assert_eq!(decoded.info.duration.unwrap().as_secs(), 143); + } } diff --git a/src/tests/ui.rs b/src/tests/ui.rs index 5849f5a..246ced4 100644 --- a/src/tests/ui.rs +++ b/src/tests/ui.rs @@ -1,3 +1,11 @@ +/* The lowfi UI: +┌─────────────────────────────┐ +│ loading │ +│ [ ] 00:00/00:00 │ +│ [s]kip [p]ause [q]uit │ +└─────────────────────────────┘ +*/ + #[cfg(test)] mod components { use crate::ui; @@ -65,21 +73,164 @@ mod window { assert!(w2.borders[1].is_empty()); } + fn sided(text: &str) -> String { + return format!("│ {text} │"); + } + #[test] - fn border_width_consistency() { - let w = Window::new(20, false); - // borders should have consistent format with width encoded - assert!(w.borders[0].len() > 0); + fn simple() { + let mut w = Window::new(3, false); + let (render, height) = w.render(vec![String::from("abc")], false, true).unwrap(); + + const MIDDLE: &str = "─────"; + assert_eq!(format!("┌{MIDDLE}┐\n{}\n└{MIDDLE}┘", sided("abc")), render); + assert_eq!(height, 3); + } + + #[test] + fn spaced() { + let mut w = Window::new(3, false); + let (render, height) = w + .render( + vec![String::from("abc"), String::from(" b"), String::from("c")], + true, + true, + ) + .unwrap(); + + const MIDDLE: &str = "─────"; + assert_eq!( + format!( + "┌{MIDDLE}┐\n{}\n{}\n{}\n└{MIDDLE}┘", + sided("abc"), + sided(" b "), + sided("c "), + ), + render + ); + assert_eq!(height, 5); } #[test] fn zero_width_window() { let w = Window::new(0, false); - // Should handle zero-width gracefully assert!(!w.borders[0].is_empty()); } } +#[cfg(test)] +mod interface { + use crossterm::style::Stylize; + use std::{sync::Arc, time::Duration}; + use tokio::time::Instant; + + use crate::{ + download::PROGRESS, + player::Current, + tracks, + ui::{ + interface::{self, Params}, + State, + }, + }; + + #[test] + fn loading() { + let sink = Arc::new(rodio::Sink::new().0); + let mut state = State::initial(sink, 3, Current::Loading(None), String::from("test")); + let menu = interface::menu(&mut state, Params::default()); + + assert_eq!(menu[0], "loading "); + assert_eq!(menu[1], " [ ] 00:00/00:00 "); + assert_eq!( + menu[2], + format!( + "{}kip {}ause {}uit", + "[s]".bold(), + "[p]".bold(), + "[q]".bold() + ) + ); + } + + #[test] + fn volume() { + let sink = Arc::new(rodio::Sink::new().0); + sink.set_volume(0.5); + let mut state = State::initial(sink, 3, Current::Loading(None), String::from("test")); + state.timer = Some(Instant::now()); + + let menu = interface::menu(&mut state, Params::default()); + + assert_eq!(menu[0], "loading "); + assert_eq!(menu[1], " volume: [///// ] 50% "); + assert_eq!( + menu[2], + format!( + "{}kip {}ause {}uit", + "[s]".bold(), + "[p]".bold(), + "[q]".bold() + ) + ); + } + + #[test] + fn progress() { + let sink = Arc::new(rodio::Sink::new().0); + PROGRESS.store(50, std::sync::atomic::Ordering::Relaxed); + let mut state = State::initial( + sink, + 3, + Current::Loading(Some(&PROGRESS)), + String::from("test"), + ); + let menu = interface::menu(&mut state, Params::default()); + + assert_eq!(menu[0], format!("loading {} ", "50%".bold())); + assert_eq!(menu[1], " [ ] 00:00/00:00 "); + assert_eq!( + menu[2], + format!( + "{}kip {}ause {}uit", + "[s]".bold(), + "[p]".bold(), + "[q]".bold() + ) + ); + } + + #[test] + fn track() { + let sink = Arc::new(rodio::Sink::new().0); + let track = tracks::Info { + path: "/path".to_owned(), + display: "Test Track".to_owned(), + width: 4 + 1 + 5, + duration: Some(Duration::from_secs(8)), + }; + + let current = Current::Track(track.clone()); + let mut state = State::initial(sink, 3, current, String::from("test")); + let menu = interface::menu(&mut state, Params::default()); + + assert_eq!( + menu[0], + format!("playing {} ", track.display.bold()) + ); + assert_eq!(menu[1], " [ ] 00:00/00:08 "); + assert_eq!( + menu[2], + format!( + "{}kip {}ause {}uit", + "[s]".bold(), + "[p]".bold(), + "[q]".bold() + ) + ); + } +} + #[cfg(test)] mod environment { use crate::ui::Environment; @@ -101,64 +252,3 @@ mod environment { } } } - -#[cfg(test)] -mod integration { - use std::sync::Arc; - - use rodio::OutputStreamBuilder; - - use crate::{player::Current, Args}; - - fn try_make_state() -> Option { - let stream = OutputStreamBuilder::open_default_stream(); - if stream.is_err() { - return None; - } - - let mut stream = stream.unwrap(); - stream.log_on_drop(false); - let sink = Arc::new(rodio::Sink::connect_new(stream.mixer())); - - let args = Args { - alternate: false, - minimalist: false, - borderless: false, - paused: false, - fps: 12, - timeout: 3, - debug: false, - width: 3, - track_list: String::from("chillhop"), - buffer_size: 5, - command: None, - }; - - let current = Current::default(); - Some(crate::ui::State::initial( - sink, - &args, - current, - String::from("list"), - )) - } - - #[test] - fn progress_bar_runs() -> Result<(), Box> { - if let Some(state) = try_make_state() { - // ensure we can call progress_bar without panic - let _ = crate::ui::components::progress_bar(&state, state.width); - } - - Ok(()) - } - - #[test] - fn action_runs() -> Result<(), Box> { - if let Some(state) = try_make_state() { - let _ = crate::ui::components::action(&state, state.width); - } - - Ok(()) - } -} diff --git a/src/tests/volume.rs b/src/tests/volume.rs deleted file mode 100644 index 5177f81..0000000 --- a/src/tests/volume.rs +++ /dev/null @@ -1,28 +0,0 @@ -#[cfg(test)] -mod volume { - use crate::volume::PersistentVolume; - - #[test] - fn float_converts_percent() { - let pv = PersistentVolume { inner: 75 }; - assert!((pv.float() - 0.75).abs() < f32::EPSILON); - } - - #[test] - fn float_zero_volume() { - let pv = PersistentVolume { inner: 0 }; - assert_eq!(pv.float(), 0.0); - } - - #[test] - fn float_full_volume() { - let pv = PersistentVolume { inner: 100 }; - assert_eq!(pv.float(), 1.0); - } - - #[test] - fn float_mid_range() { - let pv = PersistentVolume { inner: 50 }; - assert!((pv.float() - 0.5).abs() < f32::EPSILON); - } -} diff --git a/src/tracks/list.rs b/src/tracks/list.rs index b749335..13c6502 100644 --- a/src/tracks/list.rs +++ b/src/tracks/list.rs @@ -41,7 +41,7 @@ pub struct List { impl List { /// Gets the base URL of the [List]. - pub fn base(&self) -> &str { + pub fn header(&self) -> &str { self.lines[0].trim() } @@ -66,7 +66,7 @@ impl List { } /// Downloads a raw track, but doesn't decode it. - async fn download( + pub(crate) async fn download( &self, track: &str, client: &Client, @@ -76,7 +76,7 @@ impl List { let path = if track.contains("://") { track.to_owned() } else { - format!("{}{}", self.base(), track) + format!("{}{}", self.header(), track) }; let data: Bytes = if let Some(x) = path.strip_prefix("file://") { diff --git a/src/ui.rs b/src/ui.rs index 3f2caea..dffa53a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -52,7 +52,7 @@ pub struct State { pub sink: Arc, pub current: Current, pub bookmarked: bool, - timer: Option, + pub(crate) timer: Option, pub(crate) width: usize, #[allow(dead_code)] @@ -60,8 +60,8 @@ pub struct State { } impl State { - pub fn initial(sink: Arc, args: &Args, current: Current, list: String) -> Self { - let width = 21 + args.width.min(32) * 2; + pub fn initial(sink: Arc, width: usize, current: Current, list: String) -> Self { + let width = 21 + width.min(32) * 2; Self { width, sink, @@ -111,7 +111,7 @@ impl Handle { let mut window = Window::new(state.width, params.borderless); loop { - interface::draw(&mut state, &mut window, params).await?; + interface::draw(&mut state, &mut window, params)?; if let Ok(message) = rx.try_recv() { match message { diff --git a/src/ui/interface.rs b/src/ui/interface.rs index e28ce98..5f512e2 100644 --- a/src/ui/interface.rs +++ b/src/ui/interface.rs @@ -5,7 +5,7 @@ use crate::{ Args, }; -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Default)] pub struct Params { pub borderless: bool, pub minimalist: bool, @@ -25,10 +25,7 @@ impl From<&Args> for Params { } } -/// The code for the terminal interface itself. -/// -/// * `minimalist` - All this does is hide the bottom control bar. -pub async fn draw(state: &mut ui::State, window: &mut Window, params: Params) -> super::Result<()> { +pub(crate) fn menu(state: &mut ui::State, params: Params) -> Vec { let action = components::action(&state, state.width); let middle = match state.timer { @@ -45,12 +42,18 @@ pub async fn draw(state: &mut ui::State, window: &mut Window, params: Params) -> }; let controls = components::controls(state.width); - let menu = if params.minimalist { + if params.minimalist { vec![action, middle] } else { vec![action, middle, controls] - }; + } +} +/// The code for the terminal interface itself. +/// +/// * `minimalist` - All this does is hide the bottom control bar. +pub fn draw(state: &mut ui::State, window: &mut Window, params: Params) -> super::Result<()> { + let menu = menu(state, params); window.draw(menu, false)?; Ok(()) } diff --git a/src/ui/window.rs b/src/ui/window.rs index 5d74a49..8412ddb 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -22,7 +22,7 @@ pub struct Window { /// If the option to not include borders is set, these will just be empty [String]s. pub(crate) borders: [String; 2], - /// The width of the window. + /// The inner width of the window. width: usize, /// The output, currently just an [`Stdout`]. @@ -32,7 +32,7 @@ pub struct Window { impl Window { /// Initializes a new [Window]. /// - /// * `width` - Width of the windows. + /// * `width` - Inner width of the window. /// * `borderless` - Whether to include borders in the window, or not. pub fn new(width: usize, borderless: bool) -> Self { let borders = if borderless { @@ -51,8 +51,13 @@ impl Window { } } - /// Actually draws the window, with each element in `content` being on a new line. - pub fn draw(&mut self, content: Vec, space: bool) -> super::Result<()> { + pub(crate) fn render( + &mut self, + content: Vec, + space: bool, + testing: bool, + ) -> super::Result<(String, u16)> { + let linefeed = if testing { "\n" } else { "\r\n" }; let len: u16 = content.len().try_into()?; // Note that this will have a trailing newline, which we use later. @@ -64,7 +69,9 @@ impl Window { } else { String::new() }; - write!(output, "{padding} {}{space} {padding}\r\n", x.reset()).unwrap(); + + let center = if testing { x } else { x.reset().to_string() }; + write!(output, "{padding} {center}{space} {padding}{linefeed}").unwrap(); output }); @@ -72,12 +79,23 @@ impl Window { // We're doing this because Windows is stupid and can't stand // writing to the last line repeatedly. #[cfg(windows)] - let (height, suffix) = (len + 2, "\r\n"); + let (height, suffix) = (len + 3, linefeed); #[cfg(not(windows))] - let (height, suffix) = (len + 1, ""); + let (height, suffix) = (len + 2, ""); // There's no need for another newline after the main menu content, because it already has one. - let rendered = format!("{}\r\n{menu}{}{suffix}", self.borders[0], self.borders[1]); + Ok(( + format!( + "{}{linefeed}{menu}{}{suffix}", + self.borders[0], self.borders[1] + ), + height, + )) + } + + /// Actually draws the window, with each element in `content` being on a new line. + pub fn draw(&mut self, content: Vec, space: bool) -> super::Result<()> { + let (rendered, height) = self.render(content, space, false)?; crossterm::execute!( self.out, @@ -85,7 +103,7 @@ impl Window { MoveToColumn(0), Print(rendered), MoveToColumn(0), - MoveUp(height), + MoveUp(height - 1), )?; Ok(())