diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e7888e..590c11d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,3 +14,4 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo clippy -- -D warnings - run: cargo fmt -- --check + - run: cargo test --lib --tests diff --git a/.mise.toml b/.mise.toml index e1b955a..97ae0f6 100644 --- a/.mise.toml +++ b/.mise.toml @@ -22,5 +22,14 @@ done description = "Run tests" run = [ "cargo clippy -- -D warnings", - "cargo fmt -- --check" + "cargo fmt -- --check", + "cargo test --lib --tests" +] + +[tasks.coverage] +description = "Run tests with coverage" +run = [ + "cargo clippy -- -D warnings", + "cargo fmt -- --check", + "cargo tarpaulin --lib --tests" ] diff --git a/Cargo.toml b/Cargo.toml index f1adad3..6dad3e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,7 @@ keywords = ["cli", "prompt", "console"] console = "0.15.8" once_cell = "1.19.0" termcolor = "1.4.1" + +[dev-dependencies] +ctor = "0.2.6" +indoc = "2.0.4" diff --git a/examples/confirm.rs b/examples/confirm.rs index 0a4f7f5..b4e3e81 100644 --- a/examples/confirm.rs +++ b/examples/confirm.rs @@ -2,6 +2,7 @@ use demand::Confirm; fn main() { let ms = Confirm::new("Are you sure?") + .description("This will do a thing.") .affirmative("Yes!") .negative("No."); let yes = ms.run().expect("error running confirm"); diff --git a/src/confirm.rs b/src/confirm.rs index dcd03b3..23d836e 100644 --- a/src/confirm.rs +++ b/src/confirm.rs @@ -13,10 +13,10 @@ use crate::theme::Theme; /// ```rust /// use demand::Confirm; /// -/// let ms = Confirm::new("Are you sure?") +/// let confirm = Confirm::new("Are you sure?") /// .affirmative("Yes!") /// .negative("No."); -/// let yes = ms.run().expect("error running confirm"); +/// let yes = confirm.run().expect("error running confirm"); /// println!("yes: {}", yes); /// ``` pub struct Confirm<'a> { @@ -133,12 +133,11 @@ impl<'a> Confirm<'a> { let mut out = Buffer::ansi(); out.set_color(&self.theme.title)?; - write!(out, " {}", self.title)?; + writeln!(out, " {}", self.title)?; if !self.description.is_empty() { out.set_color(&self.theme.description)?; write!(out, " {}", self.description)?; - writeln!(out)?; } writeln!(out, "\n")?; @@ -201,3 +200,30 @@ impl<'a> Confirm<'a> { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::without_ansi; + use indoc::indoc; + + #[test] + fn test_render() { + let confirm = Confirm::new("Are you sure?") + .description("This will do a thing.") + .affirmative("Yes!") + .negative("No."); + + assert_eq!( + indoc! { + " Are you sure? + This will do a thing. + + Yes! No. + + ←/→ toggle • y/n/enter submit" + }, + without_ansi(confirm.render().unwrap().as_str()) + ); + } +} diff --git a/src/input.rs b/src/input.rs index f4d2b99..ac0244d 100644 --- a/src/input.rs +++ b/src/input.rs @@ -18,6 +18,7 @@ use crate::{theme, Theme}; /// .description("We'll use this to personalize your experience.") /// .placeholder("Enter your name"); /// let name = input.run().expect("error running input"); +/// ``` pub struct Input<'a> { /// The title of the input pub title: String, @@ -257,3 +258,63 @@ impl<'a> Input<'a> { Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::test::without_ansi; + + use super::*; + + #[test] + fn test_render() { + let mut input = Input::new("Title") + .description("Description") + .prompt("$ ") + .placeholder("Placeholder"); + + assert_eq!( + " Title\n Description\n $ Placeholder", + without_ansi(input.render().unwrap().as_str()) + ); + } + + #[test] + fn test_render_title() { + let mut input = Input::new("Title"); + + assert_eq!( + " Title\n > ", + without_ansi(input.render().unwrap().as_str()) + ); + } + + #[test] + fn test_render_description() { + let mut input = Input::new("Title").description("Description"); + + assert_eq!( + " Title\n Description\n > ", + without_ansi(input.render().unwrap().as_str()) + ); + } + + #[test] + fn test_render_prompt() { + let mut input = Input::new("Title").prompt("$ "); + + assert_eq!( + " Title\n $ ", + without_ansi(input.render().unwrap().as_str()) + ); + } + + #[test] + fn test_render_placeholder() { + let mut input = Input::new("Title").placeholder("Placeholder"); + + assert_eq!( + " Title\n > Placeholder", + without_ansi(input.render().unwrap().as_str()) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index a929028..0215cbc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,3 +16,6 @@ mod option; mod select; mod spinner; mod theme; + +#[cfg(test)] +mod test; diff --git a/src/multiselect.rs b/src/multiselect.rs index da41043..f88bcb0 100644 --- a/src/multiselect.rs +++ b/src/multiselect.rs @@ -15,7 +15,7 @@ use crate::{theme, DemandOption}; /// ```rust /// use demand::{DemandOption, MultiSelect}; /// -/// let ms = MultiSelect::new("Toppings") +/// let multiselect = MultiSelect::new("Toppings") /// .description("Select your toppings") /// .min(1) /// .max(4) @@ -26,8 +26,8 @@ use crate::{theme, DemandOption}; /// .option(DemandOption::new("Jalapenos").label("Jalapeños")) /// .option(DemandOption::new("Cheese")) /// .option(DemandOption::new("Vegan Cheese")) -/// .option(DemandOption::new("Nutella"));/// -/// let toppings = ms.run().expect("error running multi select"); +/// .option(DemandOption::new("Nutella")); +/// let toppings = multiselect.run().expect("error running multi select"); /// ``` pub struct MultiSelect<'a, T: Display> { /// The title of the selector @@ -58,14 +58,14 @@ pub struct MultiSelect<'a, T: Display> { impl<'a, T: Display> MultiSelect<'a, T> { /// Create a new multi select with the given title pub fn new>(title: S) -> Self { - Self { + let mut ms = MultiSelect { title: title.into(), description: String::new(), options: vec![], min: 0, max: usize::MAX, filterable: false, - theme: &*theme::DEFAULT, + theme: &theme::DEFAULT, err: None, cursor: 0, height: 0, @@ -75,7 +75,10 @@ impl<'a, T: Display> MultiSelect<'a, T> { pages: 0, cur_page: 0, capacity: 0, - } + }; + let max_height = ms.term.size().0 as usize; + ms.capacity = max_height.max(8) - 4; + ms } /// Set the description of the selector @@ -87,6 +90,16 @@ impl<'a, T: Display> MultiSelect<'a, T> { /// Add an option to the selector pub fn option(mut self, option: DemandOption) -> Self { self.options.push(option); + self.pages = self.get_pages(); + self + } + + /// Add multiple options to the selector + pub fn options(mut self, options: Vec>) -> Self { + for option in options { + self.options.push(option); + } + self.pages = self.get_pages(); self } @@ -116,12 +129,9 @@ impl<'a, T: Display> MultiSelect<'a, T> { /// Displays the selector to the user and returns their selected options pub fn run(mut self) -> io::Result> { - let max_height = self.term.size().0 as usize; - self.capacity = max_height.max(8) - 4; - self.pages = self.get_pages(); - self.max = self.max.min(self.options.len()); self.min = self.min.min(self.max); + loop { self.clear()?; let output = self.render()?; @@ -218,7 +228,7 @@ impl<'a, T: Display> MultiSelect<'a, T> { let visible_options = self.visible_options(); if self.cursor < visible_options.len().max(1) - 1 { self.cursor += 1; - } else if self.cur_page < self.pages - 1 { + } else if self.pages > 0 && self.cur_page < self.pages - 1 { self.cur_page += 1; self.cursor = 0; } @@ -240,7 +250,7 @@ impl<'a, T: Display> MultiSelect<'a, T> { } fn handle_right(&mut self) { - if self.cur_page < self.pages - 1 { + if self.pages > 0 && self.cur_page < self.pages - 1 { self.cur_page += 1; } } @@ -292,19 +302,24 @@ impl<'a, T: Display> MultiSelect<'a, T> { } if !save { self.filter.clear(); - self.pages = self.get_pages(); + self.reset_paging(); } } fn handle_filter_key(&mut self, c: char) { self.err = None; self.filter.push(c); - self.pages = self.get_pages(); + self.reset_paging(); } fn handle_filter_backspace(&mut self) { self.err = None; self.filter.pop(); + self.reset_paging(); + } + + fn reset_paging(&mut self) { + self.cur_page = 0; self.pages = self.get_pages(); } @@ -422,3 +437,41 @@ impl<'a, T: Display> MultiSelect<'a, T> { Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::test::without_ansi; + + use super::*; + use indoc::indoc; + + #[test] + fn test_render() { + let select = MultiSelect::new("Toppings") + .description("Select your toppings") + .option(DemandOption::new("Lettuce").selected(true)) + .option(DemandOption::new("Tomatoes").selected(true)) + .option(DemandOption::new("Charm Sauce")) + .option(DemandOption::new("Jalapenos").label("Jalapeños")) + .option(DemandOption::new("Cheese")) + .option(DemandOption::new("Vegan Cheese")) + .option(DemandOption::new("Nutella")); + + assert_eq!( + indoc! { + " Toppings + Select your toppings + >[•] Lettuce + [•] Tomatoes + [ ] Charm Sauce + [ ] Jalapeños + [ ] Cheese + [ ] Vegan Cheese + [ ] Nutella + + ↑/↓/k/j up/down • x/space toggle • a toggle all • enter confirm" + }, + without_ansi(select.render().unwrap().as_str()) + ); + } +} diff --git a/src/select.rs b/src/select.rs index 9da9d9f..b78e41e 100644 --- a/src/select.rs +++ b/src/select.rs @@ -14,7 +14,7 @@ use crate::{theme, DemandOption}; /// ```rust /// use demand::{DemandOption, Select}; /// -/// let ms = Select::new("Toppings") +/// let select = Select::new("Toppings") /// .description("Select your topping") /// .filterable(true) /// .option(DemandOption::new("Lettuce")) @@ -24,7 +24,7 @@ use crate::{theme, DemandOption}; /// .option(DemandOption::new("Cheese")) /// .option(DemandOption::new("Vegan Cheese")) /// .option(DemandOption::new("Nutella")); -/// let topping = ms.run().expect("error running multi select"); +/// let topping = select.run().expect("error running multi select"); /// ``` pub struct Select<'a, T: Display> { /// The title of the selector @@ -37,6 +37,7 @@ pub struct Select<'a, T: Display> { pub options: Vec>, /// Whether the selector can be filtered with a query pub filterable: bool, + cursor: usize, height: usize, term: Term, @@ -50,12 +51,12 @@ pub struct Select<'a, T: Display> { impl<'a, T: Display> Select<'a, T> { /// Create a new select with the given title pub fn new>(title: S) -> Self { - Self { + let mut s = Select { title: title.into(), description: String::new(), options: vec![], filterable: false, - theme: &*theme::DEFAULT, + theme: &theme::DEFAULT, cursor: 0, height: 0, term: Term::stderr(), @@ -64,7 +65,10 @@ impl<'a, T: Display> Select<'a, T> { pages: 0, cur_page: 0, capacity: 0, - } + }; + let max_height = s.term.size().0 as usize; + s.capacity = max_height.max(8) - 4; + s } /// Set the description of the selector @@ -76,6 +80,16 @@ impl<'a, T: Display> Select<'a, T> { /// Add an option to the selector pub fn option(mut self, option: DemandOption) -> Self { self.options.push(option); + self.pages = self.get_pages(); + self + } + + /// Add multiple options to the selector + pub fn options(mut self, options: Vec>) -> Self { + for option in options { + self.options.push(option); + } + self.pages = self.get_pages(); self } @@ -93,10 +107,6 @@ impl<'a, T: Display> Select<'a, T> { /// Displays the selector to the user and returns their selected options pub fn run(mut self) -> io::Result { - let max_height = self.term.size().0 as usize; - self.capacity = max_height.max(8) - 4; - self.pages = self.get_pages(); - loop { self.clear()?; let output = self.render()?; @@ -164,7 +174,7 @@ impl<'a, T: Display> Select<'a, T> { let visible_options = self.visible_options(); if self.cursor < visible_options.len().max(1) - 1 { self.cursor += 1; - } else if self.cur_page < self.pages - 1 { + } else if self.pages > 0 && self.cur_page < self.pages - 1 { self.cur_page += 1; self.cursor = 0; } @@ -186,7 +196,7 @@ impl<'a, T: Display> Select<'a, T> { } fn handle_right(&mut self) { - if self.cur_page < self.pages - 1 { + if self.pages > 0 && self.cur_page < self.pages - 1 { self.cur_page += 1; } } @@ -316,3 +326,37 @@ impl<'a, T: Display> Select<'a, T> { Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::test::without_ansi; + + use super::*; + use indoc::indoc; + + #[test] + fn test_render() { + let select = Select::new("Country") + .description("Select your Country") + .option(DemandOption::new("United States").selected(true)) + .option(DemandOption::new("Germany")) + .option(DemandOption::new("Brazil")) + .option(DemandOption::new("Canada")) + .option(DemandOption::new("Mexico")); + + assert_eq!( + indoc! { + " Country + Select your Country + > United States + Germany + Brazil + Canada + Mexico + + ↑/↓/k/j up/down • enter confirm" + }, + without_ansi(select.render().unwrap().as_str()) + ); + } +} diff --git a/src/spinner.rs b/src/spinner.rs index fae014c..8aa0268 100644 --- a/src/spinner.rs +++ b/src/spinner.rs @@ -178,3 +178,31 @@ impl SpinnerStyle { } } } + +#[cfg(test)] +mod test { + use crate::test::without_ansi; + + use super::*; + + #[test] + fn test_render() { + for t in vec![ + SpinnerStyle::dots(), + SpinnerStyle::jump(), + SpinnerStyle::line(), + SpinnerStyle::points(), + SpinnerStyle::meter(), + SpinnerStyle::minidots(), + SpinnerStyle::ellipsis(), + ] { + let mut spinner = Spinner::new("Loading data...").style(t); + for f in spinner.style.chars.clone().iter() { + assert_eq!( + format!("{} Loading data...", f), + without_ansi(spinner.render().unwrap().as_str()) + ); + } + } + } +} diff --git a/src/test.rs b/src/test.rs new file mode 100644 index 0000000..c1946bb --- /dev/null +++ b/src/test.rs @@ -0,0 +1,11 @@ +use std::borrow::Cow; + +#[ctor::ctor] +fn init() { + console::set_colors_enabled(false); + console::set_colors_enabled_stderr(false); +} + +pub fn without_ansi(s: &str) -> Cow { + console::strip_ansi_codes(s) +}