chore: add more tests

This commit is contained in:
talwat 2025-12-04 13:50:38 +01:00
parent b0a1a1e399
commit 95ce4f2352
10 changed files with 228 additions and 137 deletions

View File

@ -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 {

View File

@ -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());

View File

@ -2,4 +2,3 @@ mod bookmark;
mod player;
mod tracks;
mod ui;
mod volume;

View File

@ -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);
}
}

View File

@ -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<crate::ui::State> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
if let Some(state) = try_make_state() {
let _ = crate::ui::components::action(&state, state.width);
}
Ok(())
}
}

View File

@ -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);
}
}

View File

@ -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://") {

View File

@ -52,7 +52,7 @@ pub struct State {
pub sink: Arc<rodio::Sink>,
pub current: Current,
pub bookmarked: bool,
timer: Option<Instant>,
pub(crate) timer: Option<Instant>,
pub(crate) width: usize,
#[allow(dead_code)]
@ -60,8 +60,8 @@ pub struct State {
}
impl State {
pub fn initial(sink: Arc<rodio::Sink>, args: &Args, current: Current, list: String) -> Self {
let width = 21 + args.width.min(32) * 2;
pub fn initial(sink: Arc<rodio::Sink>, 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 {

View File

@ -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<String> {
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(())
}

View File

@ -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<String>, space: bool) -> super::Result<()> {
pub(crate) fn render(
&mut self,
content: Vec<String>,
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<String>, 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(())