Commits: 1

Implement Text observation

It's similar to Snippet, but simpler - doesn't have a language and meta, just content. In the report it's rendered as Markdown block quote.

To make this rendering work I implemented the prefix_with method in the Indentable trait. Under the hood indent is now using this new method to prefix it's subject with spaces.

index c30568f..b5b548c 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -56,14 +56,10 @@ This
=    ```
=
=  * There are `3` `r`s in the word `strawberry`
-  * The following table maps words to their lengths
+  * The following tables map words to their lengths
+  
+    This step demonstrates the use of tables. They can be useful to run tests on multiple samples.
=  
-    | word   | length |
-    |--------|--------|
-    | cat    | 3      |
-    | stork  | 5      |
-    | snake  | 5      |
-
=    | word   | length |
=    |--------|--------|
=    | cat    | 3      |
@@ -71,7 +67,7 @@ This
=    | rabbit | 6      |
=    | snake  | 5      |
=    | minx   | 4      |
-
+    
=    Any table within a step will be passed to the interpreter as a 2d array, like so:
=    
=    ``` json
@@ -86,12 +82,19 @@ This
=    ```
=
=    Note that heading row is not treated in any special way, and all cells are strings. It's up to an interpreter to deal with it.
-   
+
+    Just like with code blocks, you can have multiple tables.
+
+    | word    | length |
+    |---------|--------|
+    | rust    | 4      |
+    | clojure | 7      |
+    | elm     | 3      |
=   
=  * The reverse of `abc` is `cba`
=  * The reverse of `CIA` is `KGB`
=  
-    This step is intentionally wrong to allow demonstrate that TBB will not proceed with following steps.
+    This step is intentionally wrong to allow demonstrate that TBB will not proceed with following steps. The report from a failing scenario should also display more diagnostic information from each step.
=    
=  * There are `2` `o`s in the word `boost`
=
@@ -101,4 +104,4 @@ This
=tags: [math]
=```
=
-There are no steps in this scenario. It's ok. Sometimes it's easy to start by describing the behavior in prose, and write formal steps to verify it later.  A scenario like this will be listed, but won't be evaluated.
+There are no steps in this scenario. It's ok. Sometimes it's easy to start by describing the behavior in prose, and write formal steps to verify it later.  A scenario like this will be listed, but won't be evaluated and won't have any effect on the result.
index 01fc945..75f73ef 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -4,7 +4,7 @@ import json
=import unittest
=
=import spec.tbb as tbb
-from spec.tbb import step, log, send_snippet
+from spec.tbb import step, log, send_snippet, send_text
=
=
=# Nice assertions with helpful error messages
@@ -12,19 +12,19 @@ tester = unittest.TestCase()
=
=@step("Add {0} and {1} to get {2}")
=def add_and_verify(a: float, b: float, expected: float, **kwargs):
-    send_snippet("text", f"{ a } + { b } = { expected }?")
+    send_text(f"Is { a } + { b } = { expected }? I think it's {a + b}")
=
=    tester.assertEqual(expected, a + b)
=
=@step("Divide {0} by {1} to get {2}")
=def divide_and_verify(a: float, b: float, expected: float, **kwargs):
-    log.debug(f"{ a } / { b } = { expected }?")
+    send_text(f"{ a } / { b } = { expected }?")
=    
=    tester.assertAlmostEqual (expected, a / b)
=
=@step("The word {0} has {1} characters")
=def verify_characters_count(word, expected_length: int, **kwargs):
-    log.debug(f"Is '{word}' of length {expected_length}?")
+    send_text(f"Is '{word}' of length {expected_length}?")
=    actual_length = len(word)
=
=    tester.assertEqual(expected_length, actual_length)
@@ -32,12 +32,13 @@ def verify_characters_count(word, expected_length: int, **kwargs):
=@step("There are {0} properties in the following JSON")
=def count_json_properties(expected_count: int, **kwargs):
=    code = kwargs['code_blocks'][0]['value']
-    log.debug(f"Are there {expected_count} properties in the following?\n{code}")
+    send_text(f"Are there {expected_count} properties in the following?\n{code}")
=    data = json.loads(code)
=    tester.assertEqual(expected_count, len(data))
=
=@step("Subtract {0} from {1} to get {2}")
=def step_implementation_04(a: float, b: float, expected: float, **kwargs):
+    send_text(f"Is { a } - { b } = { expected }? I think it's {a - b}")
=    tester.assertAlmostEqual(expected, b - a) 
=
=@step("There are {0} {1}s in the word {2}")
@@ -48,18 +49,20 @@ def step_implementation_05(count: int, letter: str, word: str, **kwargs):
=@step("The reverse of {0} is {1}")
=def step_implementation_06(word: str, reverse: str, **kwargs):
=    actual_reverse = word[::-1]
+    send_text(f"Is '{reverse}' the reverse of '{word}'? I think the reverse is '{actual_reverse}'.")
=    tester.assertEqual(reverse, actual_reverse)
=
-@step("The following table maps words to their lengths")
+@step("The following tables map words to their lengths")
=def step_implementation_07(**kwargs):
=    for table in kwargs["tables"]:
-        send_snippet("text", f"Received a table with {len(table)} x {len(table[0])} cells")
+        send_text(f"Received a table with {len(table)} x {len(table[0])} cells")
+        send_snippet("json", json.dumps(table, indent=2))
=        
=        # Skip the first row - it's a heading
=        for [word, length] in table[1:]:
-            send_snippet("text", f"Is '{word}' {length} long?")
=            actual_length = len(word)
+            send_text(f"Is '{word}' {length} characters long? I think it's {actual_length}.")
=            tester.assertEqual(actual_length, int(length), f"the length of {word=}")
=
-
+# After you are done with the setup, call the `ready` function.
=tbb.ready()
index 76eb920..74e1b01 100644
--- a/spec/tbb.py
+++ b/spec/tbb.py
@@ -185,7 +185,17 @@ def ready():
=            break
=
=
+def send_text(content):
+    """ Send a fragment of text to be displayed in a TBB report
+    """
+    send({
+        "type": "Text",
+        "content": content,
+    })
+
=def send_snippet(language, content, meta = ""):
+    """ Send a snippet of code to be displayed in a TBB report
+    """
=    send({
=        "type": "Snippet",
=        "language": language,
index d2c897c..7b4016f 100644
--- a/src/indentable.rs
+++ b/src/indentable.rs
@@ -1,15 +1,19 @@
=pub trait Indentable {
=    fn indent(self, width: usize) -> String;
+    fn prefix_with(self, prefix: &str) -> String;
=}
=
=impl Indentable for &str {
=    fn indent(self, width: usize) -> String {
=        let spaces = " ".repeat(width);
+        self.prefix_with(&spaces)
+    }
+    fn prefix_with(self, prefix: &str) -> String {
=        self.replace("\r\n", "\n")
=            .split("\n")
-            .map(|line| spaces.clone() + line)
+            .map(|line| format!("{prefix}{line}"))
=            .reduce(|accu, line| accu + "\n" + &line)
-            .unwrap_or(spaces)
+            .unwrap_or(prefix.to_string())
=    }
=}
=
@@ -17,105 +21,205 @@ impl Indentable for &str {
=mod tests {
=    use super::*;
=
-    #[test]
-    fn test_single_line() {
-        let input = "hello";
-        let expected = "    hello";
-        // 4 spaces
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_single_line_2() {
-        let input = "hello";
-        let expected = "  hello";
-        // 2 spaces
-
-        assert_eq!(input.indent(2), expected);
-    }
-
-    #[test]
-    fn test_multiple_lines() {
-        let input = "hello\nworld";
-        let expected = "  hello\n  world";
-
-        assert_eq!(input.indent(2), expected);
+    mod prefix_with {
+        use super::*;
+
+        #[test]
+        fn test_single_line() {
+            let input = "hello";
+            let prefix = "*";
+            let expected = "*hello";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_single_line_with_whitespace() {
+            let input = "hello";
+            let prefix = "  !  ";
+            let expected = "  !  hello";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_multiple_lines() {
+            let input = "hello\nworld";
+            let prefix = "> ";
+            let expected = "> hello\n> world";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_multiple_lines_with_cariege_return() {
+            let input = "hello\r\nworld\r\nand\r\nmoon";
+            let prefix = "# ";
+            let expected = "# hello\n# world\n# and\n# moon";
+            // \r\n will be replaced with \n
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_empty_string() {
+            let input = "";
+            let prefix = "!!! ";
+            let expected = "!!! ";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_leading_line() {
+            let input = "\nhello";
+            let prefix = "Yo ";
+            let expected = "Yo \nYo hello";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_trailing_newline() {
+            let input = "hello\n";
+            let prefix = "Hey ";
+            let expected = "Hey hello\nHey ";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_multiple_empty_lines() {
+            let input = "\n\n\n";
+            let prefix = "Go!";
+            let expected = "Go!\nGo!\nGo!\nGo!";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_whitespace_in_content() {
+            let input = "  leading spaces  \n   trailing spaces ";
+            let prefix = "|";
+            let expected = "|  leading spaces  \n|   trailing spaces ";
+            // leading and trailing spaces will be preserved, so:
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_no_newline_in_string() {
+            let input = "abc";
+            let prefix = "+";
+            let expected = "+abc";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
=    }
=
-    #[test]
-    fn test_multiple_lines_with_cariege_return() {
-        let input = "hello\r\nworld\r\nand\r\nmoon";
-        let expected = "  hello\n  world\n  and\n  moon";
-        // \r\n will be replaced with \n
-
-        assert_eq!(input.indent(2), expected);
-    }
-
-    #[test]
-    fn test_empty_string() {
-        let input = "";
-        let expected = "    ";
-        // 4 spaces
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_leading_line() {
-        let input = "\nhello";
-        let expected = "    \n    hello";
-        // 4 spaces
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_trailing_newline() {
-        let input = "hello\n";
-        let expected = "    hello\n    ";
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_multiple_empty_lines() {
-        let input = "\n\n\n";
-        let expected = "    \n    \n    \n    ";
-        // four lines each containing four spaces
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_whitespace_in_content() {
-        let input = "  leading spaces  \n   trailing spaces ";
-        let expected = "      leading spaces  \n       trailing spaces ";
-        // leading and trailing spaces will be preserved, so:
-        // line 1: 4 (indentation) + 2 (original) leading spaces
-        //         2 trailing spaces untouched
-        // line 2: 4 + 3 leading spaces
-        //         1 trailing space
-        //
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_large_indent() {
-        let input = "a";
-        let expected = "                                        a";
-        // 40 spaces
-
-        assert_eq!(input.indent(40), expected);
-    }
-
-    #[test]
-    fn test_no_newline_in_string() {
-        let input = "abc";
-        let expected = "    abc";
-
-        assert_eq!(input.indent(4), expected);
+    mod indent {
+        use super::*;
+
+        #[test]
+        fn test_single_line() {
+            let input = "hello";
+            let expected = "    hello";
+            // 4 spaces
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_single_line_2() {
+            let input = "hello";
+            let expected = "  hello";
+            // 2 spaces
+
+            assert_eq!(input.indent(2), expected);
+        }
+
+        #[test]
+        fn test_multiple_lines() {
+            let input = "hello\nworld";
+            let expected = "  hello\n  world";
+
+            assert_eq!(input.indent(2), expected);
+        }
+
+        #[test]
+        fn test_multiple_lines_with_cariege_return() {
+            let input = "hello\r\nworld\r\nand\r\nmoon";
+            let expected = "  hello\n  world\n  and\n  moon";
+            // \r\n will be replaced with \n
+
+            assert_eq!(input.indent(2), expected);
+        }
+
+        #[test]
+        fn test_empty_string() {
+            let input = "";
+            let expected = "    ";
+            // 4 spaces
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_leading_line() {
+            let input = "\nhello";
+            let expected = "    \n    hello";
+            // 4 spaces
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_trailing_newline() {
+            let input = "hello\n";
+            let expected = "    hello\n    ";
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_multiple_empty_lines() {
+            let input = "\n\n\n";
+            let expected = "    \n    \n    \n    ";
+            // four lines each containing four spaces
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_whitespace_in_content() {
+            let input = "  leading spaces  \n   trailing spaces ";
+            let expected = "      leading spaces  \n       trailing spaces ";
+            // leading and trailing spaces will be preserved, so:
+            // line 1: 4 (indentation) + 2 (original) leading spaces
+            //         2 trailing spaces untouched
+            // line 2: 4 + 3 leading spaces
+            //         1 trailing space
+            //
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_large_indent() {
+            let input = "a";
+            let expected = "                                        a";
+            // 40 spaces
+
+            assert_eq!(input.indent(40), expected);
+        }
+
+        #[test]
+        fn test_no_newline_in_string() {
+            let input = "abc";
+            let expected = "    abc";
+
+            assert_eq!(input.indent(4), expected);
+        }
=    }
=}
=
index 0906f9c..3ae8227 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -170,6 +170,9 @@ impl<'a> ScenarioReport<'a> {
=
=            loop {
=                match Self::receive(&mut reader)? {
+                    InterpreterMessage::Text { content } => {
+                        observations.push(Observation::Text { content })
+                    }
=                    InterpreterMessage::Snippet {
=                        language,
=                        meta,
@@ -199,7 +202,7 @@ impl<'a> ScenarioReport<'a> {
=                        // Maybe this can be configured via front-matter?
=                        break 'steps_loop;
=                    }
-                    unexpected => {
+                    unexpected @ InterpreterMessage::InterpreterState { .. } => {
=                        return Err(anyhow!(
=                            "unexpected message received from the interpreter: {unexpected:#?}"
=                        ));
@@ -279,11 +282,19 @@ pub struct StepReport<'a> {
=
=#[derive(Debug, Serialize)]
=pub enum Observation {
+    Text {
+        content: String,
+    },
=    Snippet {
=        language: String,
=        meta: String,
=        content: String,
=    },
+    // Image,
+    // Audio,
+    // Video,
+    // Attachment,
+    // Link,
=}
=
=impl Display for Observation {
@@ -298,6 +309,9 @@ impl Display for Observation {
=                writeln!(f, "{content}")?;
=                writeln!(f, "```")
=            }
+            Observation::Text { content } => {
+                writeln!(f, "{}", content.prefix_with("> "))
+            }
=        }
=    }
=}
@@ -474,6 +488,9 @@ pub enum InterpreterMessage {
=    InterpreterState {
=        ready: bool,
=    },
+    Text {
+        content: String,
+    },
=    Snippet {
=        language: String,
=        meta: String,