diff --git a/src/ast.rs b/src/ast.rs new file mode 100644 index 0000000..0d54363 --- /dev/null +++ b/src/ast.rs @@ -0,0 +1,108 @@ +/// A test context is used to pass state between different steps of a test case. +pub trait TestContext { + fn new() -> Self; +} + + +pub enum TestCase { + Background(Scenario), + Scenario(Scenario), +} + +impl TestCase { + pub fn name(&self) -> Option { + match self { + TestCase::Background(s) => s.name.clone(), + TestCase::Scenario(s) => s.name.clone() + } + } + + + pub fn eval(&self, context: C) -> TestResult { + match self { + TestCase::Background(s) => s.eval(context), + TestCase::Scenario(s) => s.eval(context) + } + } +} + +pub struct TestResult { + pub test_case_name: String, + pub pass: bool, + pub context: C +} + +/// A feature is a collection of test cases. +pub struct Feature { + pub name: String, + pub comment: String, + pub background: Option>, + pub test_cases: Vec>, +} + +impl Feature { + pub fn eval(&self) -> Vec> { + + let mut results = vec![]; + for tc in self.test_cases.iter() { + let mut context = C::new(); + + if let Some(TestCase::Background(ref bg)) = self.background { + match bg.eval(context) { + mut r @ TestResult { pass: false, ..} => { + r.test_case_name = "".to_string(); + results.push(r); + continue; + }, + TestResult { pass: true, context: c, ..} => { + context = c; + } + } + } + + results.push(tc.eval(context)); + } + + results + } +} + +pub struct Scenario { + pub name: Option, + pub steps: Vec>>, +} + +impl Scenario { + /// Execute a scenario by running each step in order, with mutable access to + /// the context. + pub fn eval(&self, mut context: C) -> TestResult { + for s in self.steps.iter() { + if !s.eval(&mut context) { + return TestResult { + test_case_name: match self.name.as_ref() { + Some(s) => s.clone(), + None => "".to_string() + }, + pass: false, + context: context + }; + } + } + + TestResult { + test_case_name: match self.name.as_ref() { + Some(s) => s.clone(), + None => "".to_string() + }, + pass: true, + context: context + } + } +} + +/// A specific step which makes up a scenario. Users should create their own +/// implementations of this trait, which are returned by their step parsers. +pub trait Step { + fn eval(&self, &mut C) -> bool; +} + diff --git a/src/feature.rs b/src/feature.rs deleted file mode 100644 index dbff466..0000000 --- a/src/feature.rs +++ /dev/null @@ -1,163 +0,0 @@ -use combine::ParseError; -use combine::Parser; -use combine::Stream; - -use combine::char::{newline, string}; -use combine::sep_by; -use parse_utils::{line_block, until_eol}; - -/// A test context is used to pass state between different steps of a test case. -/// It may also be initialized at the feature level via a Background (TODO) -pub trait TestContext { - fn new() -> Self; -} - -/// A test case is a generalization of a Scenario; might also be a PropTest. -pub trait TestCase { - fn name(&self) -> String; - fn eval(&self, C) -> (bool, C); -} - -/// A feature is a collection of tests. -// TODO: Implement background -pub struct Feature { - pub name: String, - pub comment: String, - pub test_cases: Vec>>, -} - -impl Feature { - pub fn eval(&self) -> (bool, C) { - let mut context = C::new(); - - for tc in self.test_cases.iter() { - let (pass, context_) = tc.eval(context); - context = context_; - - if !pass { - return (false, context); - } - } - - (true, context) - } -} - -/// The output of a test case parser. Ideally this would just be Box -/// but there's some subtle issue to do with using trait objects in an -/// associated type, perhaps in concert with the 'impl trait' feature, that -/// keeps it from working. Wrapping it in a struct is a workaround. -pub struct BoxedTestCase { - pub val: Box>, -} - -/// Construct a feature file parser, built around a test-case parser. -pub fn parser(test_case_parser: TP) -> impl Parser> -where - C: TestContext, - TP: Parser>, - I: Stream, - I::Error: ParseError, -{ - let test_cases = - sep_by(test_case_parser, newline()).map(|results: Vec>| { - results.into_iter().map(|result| result.val).collect() - }); - - struct_parser! { - Feature { - _: string("Feature: "), - name: until_eol(), - comment: line_block(), - _: newline(), - test_cases: test_cases - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use combine::stream::easy; - use combine::stream::state::State; - - /// The sample test case just records each test in this context as it runs - struct SampleTestContext { - executed_cases: Vec, - executed_contents: Vec, - } - - impl TestContext for SampleTestContext { - fn new() -> SampleTestContext { - SampleTestContext { - executed_cases: vec![], - executed_contents: vec![], - } - } - } - - struct SampleTestCase { - name: String, - content: String, - } - - impl TestCase for SampleTestCase { - fn name(&self) -> String { - self.name.clone() - } - - fn eval(&self, mut context: SampleTestContext) -> (bool, SampleTestContext) { - context.executed_cases.push(self.name.clone()); - context.executed_contents.push(self.content.clone()); - (true, context) - } - } - - fn sample_test_case_parser<'a, I>() -> impl Parser> - where - I: Stream, - I::Error: ParseError, - { - let p = struct_parser!{ - SampleTestCase { - _: string("Sample Test Case: "), - name: until_eol(), - content: line_block(), - } - }; - - p.map(|stc| BoxedTestCase { val: Box::new(stc) }) - } - - #[test] - fn feature() { - let s = "Feature: my feature\n\ - comment line one\n\ - comment line two\n\ - \n\ - Sample Test Case: first\n\ - Content one\n\ - \n\ - Sample Test Case: second\n\ - Content two\n"; - - let (feat, remaining) = parser(sample_test_case_parser()) - .easy_parse(State::new(s)) - .unwrap(); - println!("End state: {:#?}", remaining); - - assert_eq!(feat.name, "my feature".to_string()); - assert_eq!(feat.comment, "comment line one\ncomment line two".to_string()); - assert_eq!(feat.test_cases.len(), 2); - assert_eq!(feat.test_cases[0].name(), "first".clone()); - assert_eq!(feat.test_cases[1].name(), "second".clone()); - - let (pass, ctx) = feat.eval(); - assert!(pass); - assert_eq!(ctx.executed_cases, - vec!("first".to_string(), "second".to_string())); - assert_eq!(ctx.executed_contents, - vec!("Content one".to_string(), "Content two".to_string())); - } -} diff --git a/src/lib.rs b/src/lib.rs index 8712d46..a149372 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,8 @@ extern crate combine; extern crate itertools; -pub mod feature; -pub mod scenario; +pub mod parser; +pub mod ast; pub mod parse_utils; diff --git a/src/parse_utils.rs b/src/parse_utils.rs index eafaf49..7fb41e7 100644 --- a/src/parse_utils.rs +++ b/src/parse_utils.rs @@ -5,7 +5,7 @@ use combine::Stream; use combine::ParseError; use combine::char::newline; -use combine::{Parser, many, many1, none_of, try}; +use combine::{Parser, many, many1, none_of, try, eof}; /// Match a single non-newline character /// @@ -31,9 +31,36 @@ where I: Stream, none_of("\r\n".chars()) } -/// Parse one or more characters up to the next newline character, consuming it. -/// Return the characters consumed on the way to the newline, but not the -/// newline. + +/// Parse either a newline() or an end-of-file marker, consuming any parsed +/// characters. +/// # Examples +// +/// ``` +/// # extern crate combine; +/// # extern crate rherkin; +/// # use combine::*; +/// # use rherkin::parse_utils::eol; +/// # fn main() { +/// let mut parser1 = eol(); +/// let result1 = parser1.parse("\n"); +/// assert_eq!(result1, Ok(((), ""))); +/// +/// let mut parser2 = eol(); +/// let result2 = parser2.parse(""); +/// assert_eq!(result2, Ok(((), ""))); +/// # } +/// ``` +pub fn eol() -> impl Parser +where I: Stream, + I::Error: ParseError +{ + choice! { newline().map(|_| ()), eof() } +} + +/// Parse one or more characters up to the end of line, using `eol()`. Return +/// the characters consumed on the way to the line end, but not any newline +/// character. /// /// # Examples // @@ -43,17 +70,20 @@ where I: Stream, /// # use combine::*; /// # use rherkin::parse_utils::until_eol; /// # fn main() { -/// let mut parser = until_eol(); -/// let result = parser.parse("abc\ndef"); -/// assert_eq!(result, Ok(("abc".to_string(), "def"))); +/// let mut parser1 = until_eol(); +/// let result1 = parser1.parse("abc\ndef"); +/// assert_eq!(result1, Ok(("abc".to_string(), "def"))); +/// +/// let mut parser2 = until_eol(); +/// let result2 = parser2.parse("def"); +/// assert_eq!(result2, Ok(("def".to_string(), ""))); /// # } /// ``` pub fn until_eol() -> impl Parser where I: Stream, I::Error: ParseError { - ( many1(non_newline()), newline() ) - .map(|(s, _)| s) + ( many1(non_newline()), eol()).map(|(s, _)| s) } diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..295cfc1 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,244 @@ +use ast::{Feature, Scenario, Step, TestCase, TestContext}; +use itertools; + +use combine::ParseError; +use combine::Parser; +use combine::Stream; + +use combine::char::{newline, string}; +use combine::{many, many1, optional, sep_by, token}; +use parse_utils::{eol, line_block, until_eol}; + +pub struct BoxedStep { + pub val: Box>, +} + +fn scenario_block( + prefix: &'static str, + inner: P, +) -> impl Parser>> +where + TC: TestContext + 'static, + P: Parser> + Clone, + I: Stream, + I::Error: ParseError, +{ + let first_line = (string(prefix), token(' '), inner.clone(), eol()).map(|t| t.2); + let and_line = (string("And "), inner, eol()).map(|t| t.1); + (first_line, many(and_line)).map(|(first, mut ands): (BoxedStep, Vec>)| { + ands.insert(0, first); + ands + }) +} + +fn scenario( + prefix: &'static str, + given: GP, + when: WP, + then: TP, +) -> impl Parser> +where + C: TestContext + 'static, + GP: Parser> + Clone, + WP: Parser> + Clone, + TP: Parser> + Clone, + I: Stream, + I::Error: ParseError, +{ + let givens = scenario_block("Given", given); + let whens = scenario_block("When", when); + let thens = scenario_block("Then", then); + + let steps = ( + optional(givens).map(|o| o.unwrap_or(vec![])), + optional(whens).map(|o| o.unwrap_or(vec![])), + optional(thens).map(|o| o.unwrap_or(vec![])), + ).map(|(g, w, t)| itertools::concat(vec![g, w, t])); + + struct_parser! { + Scenario { + _: string(prefix), + _: string(":"), + name: choice!( + until_eol().map(|s| Some(s.trim().to_string())), + newline().map(|_| None) + ), + steps: steps.map(|x| x.into_iter().map(|s| s.val).collect()), + } + } +} + +/// Construct a feature file parser, built around step parsers +/// +/// # Arguments +/// +/// * `given`, `when`, `then` : User-defined parsers to parse and produce +/// `Step`s out of the text after `Given`, `When`, and `Then`, respectively. +pub fn feature( + given: GP, + when: WP, + then: TP, +) -> impl Parser> +where + C: TestContext + 'static, + GP: Parser> + Clone, + WP: Parser> + Clone, + TP: Parser> + Clone, + I: Stream, + I::Error: ParseError, +{ + let blank_lines = || many1::, _>(newline()); + + let background = optional( + ( + scenario("Background", given.clone(), when.clone(), then.clone()), + blank_lines(), + ).map(|t| TestCase::Background(t.0)) + ); + + let test_cases = sep_by( + scenario("Scenario", given, when, then).map(|s| TestCase::Scenario(s)), + blank_lines()); + + struct_parser! { + Feature { + _: optional(blank_lines()), + _: string("Feature: "), + name: until_eol(), + comment: line_block(), + _: blank_lines(), + background: background, + test_cases: test_cases + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use combine::stream::state::State; + + /// The sample test case just records each step as it runs + struct SampleTestContext { + executed_steps: Vec, + } + + impl TestContext for SampleTestContext { + fn new() -> SampleTestContext { + SampleTestContext { + executed_steps: vec![], + } + } + } + + struct SampleStep { + num: u32, + } + + impl Step for SampleStep { + fn eval(&self, context: &mut SampleTestContext) -> bool { + context.executed_steps.push(self.num); + true + } + } + + fn do_parse(s: &str) -> Feature { + use combine::char::digit; + use combine::token; + + let num_digit = || digit().map(|c| c.to_digit(10).unwrap()); + let given = struct_parser! { SampleStep { _: token('G'), num: num_digit() } }; + let when = struct_parser! { SampleStep { _: token('W'), num: num_digit() } }; + let then = struct_parser! { SampleStep { _: token('T'), num: num_digit() } }; + + let (feat, remaining) = feature( + given.map(|x| BoxedStep { val: Box::new(x) }), + when.map(|x| BoxedStep { val: Box::new(x) }), + then.map(|x| BoxedStep { val: Box::new(x) }), + ).easy_parse(State::new(s)) + .unwrap(); + + println!("End state: {:#?}", remaining); + + feat + } + + #[test] + fn test_parse() { + let feat = do_parse( + r" +Feature: my feature +one +two + +Background: +Given G1 + +Scenario: One +Given G2 +When W3 +Then T4 + +Scenario: Two +Given G2 +And G3 +When W4 +And W5 +Then T6 +And T7"); + + assert_eq!(feat.name, "my feature".to_string()); + assert_eq!(feat.comment, "one\ntwo".to_string()); + assert!(feat.background.is_some()); + assert_eq!(feat.test_cases.len(), 2); + assert_eq!(feat.test_cases[0].name(), Some("One".to_string())); + assert_eq!(feat.test_cases[1].name(), Some("Two".to_string())); + + let results = feat.eval(); + + assert_eq!(results[0].pass, true); + assert_eq!(results[0].test_case_name, "One".to_string()); + assert_eq!(results[0].context.executed_steps, [1, 2, 3, 4]); + + assert_eq!(results[1].pass, true); + assert_eq!(results[1].test_case_name, "Two".to_string()); + assert_eq!(results[1].context.executed_steps, [1, 2, 3, 4, 5, 6, 7]); + } + + #[test] + fn test_parse_extra_whitespace() { + let feat = do_parse( + r" +Feature: my feature + + +Scenario: One +Given G1 +When W1 +Then T1 + + +Scenario: Two +Given G2 +When W2 +Then T2 +", + ); + + assert_eq!(feat.name, "my feature".to_string()); + assert_eq!(feat.comment, "".to_string()); + assert_eq!(feat.test_cases.len(), 2); + assert_eq!(feat.test_cases[0].name(), Some("One".to_string())); + assert_eq!(feat.test_cases[1].name(), Some("Two".to_string())); + + let results = feat.eval(); + + assert_eq!(results[0].pass, true); + assert_eq!(results[0].test_case_name, "One".to_string()); + assert_eq!(results[0].context.executed_steps, [1, 1, 1]); + + assert_eq!(results[1].pass, true); + assert_eq!(results[1].test_case_name, "Two".to_string()); + assert_eq!(results[1].context.executed_steps, [2, 2, 2]); + } +} diff --git a/src/scenario.rs b/src/scenario.rs deleted file mode 100644 index 3ef44b4..0000000 --- a/src/scenario.rs +++ /dev/null @@ -1,187 +0,0 @@ -use combine::ParseError; -use combine::Parser; -use combine::Stream; -use itertools; - -use feature::{BoxedTestCase, TestCase, TestContext}; - -struct Scenario { - name: String, - steps: Vec>>, -} - -/// A specific step which makes up a test context. Users should create there own -/// implementations of this trait, which are returned by their step parsers. -pub trait Step { - fn eval(&self, &mut C) -> bool; -} - -impl TestCase for Scenario { - fn name(&self) -> String { - self.name.clone() - } - - /// Execute a scenario by creating a new test context, then running each - /// step in order with mutable access to the context. - fn eval(&self, mut context: C) -> (bool, C) { - // let mut ctx = TC::new(); - for s in self.steps.iter() { - if !s.eval(&mut context) { - return (false, context); - } - } - - (true, context) - } -} - -pub struct BoxedStep { - pub val: Box>, -} - -fn scenario_block_parser( - prefix: &'static str, - inner: P, -) -> impl Parser>> -where - TC: TestContext + 'static, - P: Parser> + Clone, - I: Stream, - I::Error: ParseError, -{ - use combine::char::{newline, string}; - use combine::{many, token}; - - let first_line = (string(prefix), token(' '), inner.clone(), newline()).map(|t| t.2); - let and_line = (string("And "), inner, newline()).map(|t| t.1); - (first_line, many(and_line)).map(|(first, mut ands): (BoxedStep, Vec>)| { - ands.insert(0, first); - ands - }) -} - -/// A `TestCase` parser for classic Cucumber-style Scenarios; this parser (or a -/// composition thereof) should be passed to feature::parser. -/// -/// # Arguments -/// -/// * `given`, `when`, `then` : User-defined parsers to parse and produce -/// `Step`s out of the text after `Given`, `When`, and `Then`, respectively. -pub fn parser( - given: GP, - when: WP, - then: TP, -) -> impl Parser> -where - TC: TestContext + 'static, - GP: Parser> + Clone, - WP: Parser> + Clone, - TP: Parser> + Clone, - I: Stream, - I::Error: ParseError, -{ - use combine::char::{newline, string}; - use combine::{many, none_of, optional, Parser}; - - let scenario_prefix = || string("Scenario: "); - - let non_newline = || none_of("\r\n".chars()); - - // Parse until a newline; return everything before the newline character. - let until_eol = || (many(non_newline()), newline()).map(|(s, _): (String, _)| s); - - // Parse a line with the given prefix - let prefixed_line = |prefix| (prefix, until_eol()).map(|(_, text): (_, String)| text); - - let givens = scenario_block_parser("Given", given); - let whens = scenario_block_parser("When", when); - let thens = scenario_block_parser("Then", then); - - let steps = ( - optional(givens).map(|o| o.unwrap_or(vec![])), - optional(whens).map(|o| o.unwrap_or(vec![])), - optional(thens).map(|o| o.unwrap_or(vec![])), - ).map(|(g, w, t)| itertools::concat(vec![g, w, t])); - - let scenario = (prefixed_line(scenario_prefix()), steps).map( - |(name, steps): (String, Vec>)| Scenario { - name: name, - steps: steps.into_iter().map(|s| s.val).collect(), - }, - ); - - scenario.map(|sc| BoxedTestCase { val: Box::new(sc) }) -} - -#[cfg(test)] -mod tests { - use super::*; - - use feature; - use combine::stream::state::State; - - /// The sample test case just records each step as it runs - struct SampleTestContext { - executed_steps: Vec, - } - - impl TestContext for SampleTestContext { - fn new() -> SampleTestContext { - SampleTestContext { - executed_steps: vec![], - } - } - } - - struct SampleStep { - num: u32, - } - - impl Step for SampleStep { - fn eval(&self, context: &mut SampleTestContext) -> bool { - context.executed_steps.push(self.num); - true - } - } - - #[test] - fn scenario() { - let s = "Feature: my feature\n\ - \n\ - Scenario: One\n\ - Given G1\n\ - When W1\n\ - Then T1\n\ - \n\ - Scenario: Two\n\ - Given G2\n\ - When W2\n\ - Then T2\n"; - - use combine::char::digit; - use combine::token; - - let num_digit = || digit().map(|c| c.to_digit(10).unwrap()); - let given = struct_parser! { SampleStep { _: token('G'), num: num_digit() } }; - let when = struct_parser! { SampleStep { _: token('W'), num: num_digit() } }; - let then = struct_parser! { SampleStep { _: token('T'), num: num_digit() } }; - - let (feat, remaining) = feature::parser(parser( - given.map(|x| BoxedStep { val: Box::new(x) }), - when.map(|x| BoxedStep { val: Box::new(x) }), - then.map(|x| BoxedStep { val: Box::new(x) }), - )).easy_parse(State::new(s)) - .unwrap(); - println!("End state: {:#?}", remaining); - - assert_eq!(feat.name, "my feature".to_string()); - assert_eq!(feat.comment, "".to_string()); - assert_eq!(feat.test_cases.len(), 2); - assert_eq!(feat.test_cases[0].name(), "One".clone()); - assert_eq!(feat.test_cases[1].name(), "Two".clone()); - - let (pass, ctx) = feat.eval(); - assert!(pass); - assert_eq!(ctx.executed_steps, vec![1, 1, 1, 2, 2, 2]); - } -} diff --git a/tests/calculator.rs b/tests/calculator.rs index 76ff22c..30581d0 100644 --- a/tests/calculator.rs +++ b/tests/calculator.rs @@ -6,8 +6,9 @@ use combine::stream::state::State; use combine::char::{string, digit}; extern crate rherkin; -use rherkin::feature::{self, TestContext}; -use rherkin::scenario::{self, Step, BoxedStep}; +//use rherkin::feature; +//use rherkin::scenario::{self, Step, BoxedStep, TestContext}; +use rherkin::{ast, parser}; // An rpn calculator, something we can write tests for. #[derive(Debug)] @@ -80,7 +81,7 @@ impl Calculator { } } -impl TestContext for Calculator { +impl ast::TestContext for Calculator { fn new() -> Calculator { Calculator { current: vec!(), @@ -93,7 +94,7 @@ mod steps { use super::*; pub struct Clear { } - impl Step for Clear { + impl ast::Step for Clear { fn eval(&self, calc: &mut Calculator) -> bool { println!("Clear"); calc.current = vec!(); @@ -103,7 +104,7 @@ mod steps { } pub struct Press { pub button: Button } - impl Step for Press { + impl ast::Step for Press { fn eval(&self, calc: &mut Calculator) -> bool { println!("Press {:?}", self.button); calc.press(&self.button) @@ -111,7 +112,7 @@ mod steps { } pub struct CheckDisplay { pub expected: String } - impl Step for CheckDisplay { + impl ast::Step for CheckDisplay { fn eval(&self, calc: &mut Calculator) -> bool { let actual = calc.stack.last(); println!("Check display: expected {:?}, actual {:#?}", self.expected, actual); @@ -177,15 +178,34 @@ Then the display should read 2 let then = choice! { check_display }; let mut p = - feature::parser( - scenario::parser( - given.map(|x| BoxedStep { val: Box::new(x) }), - when.map(|x| BoxedStep { val: Box::new(x) }), - then.map(|x| BoxedStep { val: Box::new(x) }))); + parser::feature( + given.map(|x| parser::BoxedStep { val: Box::new(x) }), + when.map (|x| parser::BoxedStep { val: Box::new(x) }), + then.map (|x| parser::BoxedStep { val: Box::new(x) })); - let (f, remaining) = p.easy_parse(State::new(spec)).unwrap(); + let (f, _remaining) = p.easy_parse(State::new(spec)).unwrap(); - let (success, _) = f.eval(); - assert!(success); + let results = f.eval(); + for r in results { + assert!(r.pass); + } } + +// fn proptests() { +// let spec = r#" +// Feature: RPN Calculator Property Specs + +// PropSpec: arbitrary addition +// Given a fresh calculator +// And a number A less than 10000 +// And a number B less than 10000 +// When I enter the number A +// And I press enter +// And I enter the number B +// And I press plus +// Then the displayed value should be less than 20000 +// "#; + +// assert!(true) +// }