From c57e6abf398474c9d7bf9f8a51ab3c0020d8aee8 Mon Sep 17 00:00:00 2001 From: Roland Schaer Date: Tue, 23 Jan 2024 12:10:05 -0300 Subject: [PATCH 1/4] feat: add tests to verify initial rendering --- .mise.toml | 11 +++++++- Cargo.toml | 4 +++ examples/confirm.rs | 1 + src/confirm.rs | 31 +++++++++++++++++++++-- src/input.rs | 61 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 +++ src/multiselect.rs | 37 ++++++++++++++++++++++++--- src/select.rs | 33 ++++++++++++++++++++++-- src/spinner.rs | 28 +++++++++++++++++++++ src/test.rs | 11 ++++++++ 10 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 src/test.rs 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..8b77dea 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> { @@ -201,3 +201,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..cc0dbb3 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_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()) + ); + } + + #[test] + fn test_render_all() { + 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()) + ); + } +} 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..3253452 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 @@ -422,3 +422,34 @@ 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 + + ↑/↓/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..7825cfa 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 @@ -316,3 +316,32 @@ 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 + + ↑/↓/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) +} From 58b2b407a88ce9ce10bb6a01b9e8415723d52889 Mon Sep 17 00:00:00 2001 From: Roland Schaer Date: Tue, 23 Jan 2024 13:03:31 -0300 Subject: [PATCH 2/4] fix: confirm - description should render on new line --- src/confirm.rs | 7 +++---- src/input.rs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/confirm.rs b/src/confirm.rs index 8b77dea..23d836e 100644 --- a/src/confirm.rs +++ b/src/confirm.rs @@ -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")?; @@ -217,8 +216,8 @@ mod tests { assert_eq!( indoc! { - " Are you sure? This will do a thing. - + " Are you sure? + This will do a thing. Yes! No. diff --git a/src/input.rs b/src/input.rs index cc0dbb3..cdf86ce 100644 --- a/src/input.rs +++ b/src/input.rs @@ -18,7 +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, From 138928982e2570112b24c5123144f7bcf975361c Mon Sep 17 00:00:00 2001 From: Roland Schaer Date: Tue, 23 Jan 2024 15:40:23 -0300 Subject: [PATCH 3/4] fix: select - address missing option output in render function --- src/input.rs | 26 +++++++++++++------------- src/multiselect.rs | 46 ++++++++++++++++++++++++++++++++++------------ src/select.rs | 35 +++++++++++++++++++++++++---------- 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/src/input.rs b/src/input.rs index cdf86ce..ac0244d 100644 --- a/src/input.rs +++ b/src/input.rs @@ -265,6 +265,19 @@ mod tests { 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"); @@ -304,17 +317,4 @@ mod tests { without_ansi(input.render().unwrap().as_str()) ); } - - #[test] - fn test_render_all() { - 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()) - ); - } } diff --git a/src/multiselect.rs b/src/multiselect.rs index 3253452..f88bcb0 100644 --- a/src/multiselect.rs +++ b/src/multiselect.rs @@ -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(); } @@ -446,7 +461,14 @@ mod tests { 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 7825cfa..b78e41e 100644 --- a/src/select.rs +++ b/src/select.rs @@ -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; } } @@ -338,7 +348,12 @@ mod tests { indoc! { " Country Select your Country - + > United States + Germany + Brazil + Canada + Mexico + ↑/↓/k/j up/down • enter confirm" }, without_ansi(select.render().unwrap().as_str()) From dd26250bc95634a2b7061c321a6970144ba38720 Mon Sep 17 00:00:00 2001 From: Roland Schaer Date: Tue, 23 Jan 2024 16:38:08 -0300 Subject: [PATCH 4/4] fix: add cargo test command to GH workflow --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) 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