mirror of
https://github.com/talwat/lowfi
synced 2025-12-07 23:47:46 +00:00
chore: add more tests
This commit is contained in:
parent
b0a1a1e399
commit
95ce4f2352
@ -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 {
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -2,4 +2,3 @@ mod bookmark;
|
||||
mod player;
|
||||
mod tracks;
|
||||
mod ui;
|
||||
mod volume;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
222
src/tests/ui.rs
222
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<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(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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://") {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user