Commits: 9

Use Python unittest assertion for better messages

It has specialized functions for different assertions, and includes compared values in assertion errors.

index f5bbff3..52c10db 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -1,5 +1,6 @@
=#!/usr/bin/env python3
=
+import unittest
=import json
=import logging
=import os
@@ -84,7 +85,9 @@ def ready():
=
=# User code
=# =========
-        
+
+tester = unittest.TestCase()
+
=@step("Add {0} and {1} to get {2}")
=def add_and_verify(a, b, expected, **kwargs):
=    log.debug(f"{ a } + { b } = { expected }?")
@@ -94,7 +97,7 @@ def add_and_verify(a, b, expected, **kwargs):
=    b = float(b)
=    expected = float(expected)
=    
-    assert a + b == expected, f"{ a } + { b } = { a + b }, not { expected }!"
+    tester.assertEqual(expected, a + b)
=
=
=@step("Divide {0} by {1} to get {2}")
@@ -106,7 +109,7 @@ def divide_and_verify(a, b, expected, **kwargs):
=    b = float(b)
=    expected = float(expected)
=    
-    assert a / b == expected, f"{ a } / { b } = { a / b }, not { expected }!"
+    tester.assertAlmostEqual (expected, a / b)
=
=
=@step("The word {0} has {1} characters")
@@ -115,7 +118,7 @@ def verify_characters_count(word: str, expected_length, **kwargs):
=    expected_length = int(expected_length)
=    actual_length = len(word)
=
-    assert expected_length == actual_length, f"The word '{word}' is {actual_length} long, not {expected_length}"
+    tester.assertEqual(expected_length, actual_length)
=
=    
=

Implement automatic argument parsing in python

The python interpreter now can parse arguments into types indicated by step function annotation, i.e. if a function looks like this:

def fun(a: float, b):
    # ...

Then the first argument coming from tbb will be converted to float. Second parameter is not annotated, so the argument will be passed as string.

This will only work for a type with a constructor that accepts a string.

index aed3aea..e7d619c 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,11 @@ Support for Linux, OS X and Web (WASM).
=- [ ] Use for evaluating Jewiet's Form to Mail specification
=- [ ] Helper libraries
=    - [ ] for Python
+      - [x] Proof-of-concept
+      - [x] `@step` decorator
+      - [x] Automatic arguments conversion (casting)
+      - [x] Insightful assertion errors
+      - [ ] Split library code from the basic interpreter
=    - [ ] for Clojure
=- [ ] Capture more data in reports
=  - [ ] Attachments (screenshots, videos, datasets, etc.)
index 52c10db..b472e3c 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -1,6 +1,7 @@
=#!/usr/bin/env python3
=
=import unittest
+import inspect
=import json
=import logging
=import os
@@ -65,6 +66,18 @@ def ready():
=            if implementation:
=                log.debug(f"Found an implementation of '{ variant }'")
=                try:
+                    signature = inspect.signature(implementation)
+                    log.info(f"Signature: {signature}")
+                    for index, key in enumerate(signature.parameters):
+                        parameter = signature.parameters[key]
+                        log.info(f"Processing parameter {index} ({parameter.annotation})")
+                        if parameter.annotation == inspect._empty:
+                            log.info (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
+                            continue 
+
+                        log.info (f"Converting argument {index} to {parameter.annotation.__name__}")
+                        arguments[index] = parameter.annotation(arguments[index])
+
=                    implementation(*arguments, **step)
=                    send({ "type": 'Success' })
=                except Exception as error:
@@ -89,37 +102,25 @@ def ready():
=tester = unittest.TestCase()
=
=@step("Add {0} and {1} to get {2}")
-def add_and_verify(a, b, expected, **kwargs):
+def add_and_verify(a: float, b: float, expected: float, **kwargs):
=    log.debug(f"{ a } + { b } = { expected }?")
=
-    # TODO: Can we do this conversion automatically based on typing information?
-    a = float(a)
-    b = float(b)
-    expected = float(expected)
-    
=    tester.assertEqual(expected, a + b)
=
=
=@step("Divide {0} by {1} to get {2}")
-def divide_and_verify(a, b, expected, **kwargs):
+def divide_and_verify(a: float, b: float, expected: float, **kwargs):
=    log.debug(f"{ a } / { b } = { expected }?")
-
-    # TODO: Can we do this conversion automatically based on typing information?
-    a = float(a)
-    b = float(b)
-    expected = float(expected)
=    
=    tester.assertAlmostEqual (expected, a / b)
=
=
=@step("The word {0} has {1} characters")
-def verify_characters_count(word: str, expected_length, **kwargs):
-    log.info(word)
-    expected_length = int(expected_length)
+def verify_characters_count(word, expected_length: int, **kwargs):
+    log.info(f"Is '{word}' of length {expected_length}?")
=    actual_length = len(word)
=
=    tester.assertEqual(expected_length, actual_length)
=
-    
=  
=ready()

Update the roadmap

index e7d619c..142bd8a 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ Support for Linux, OS X and Web (WASM).
=  - [ ] A summary at the bottom (esp. list of errors)
=  - [x] Use colors
=  - [ ] More instructive error messages
+  - [ ] Indent multiline error messages
=- [ ] Pass more step data to interpreters
=    - [ ] Code blocks
=    - [ ] Lists

Let basic.py chill with the logging

index b472e3c..08a839a 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -67,15 +67,15 @@ def ready():
=                log.debug(f"Found an implementation of '{ variant }'")
=                try:
=                    signature = inspect.signature(implementation)
-                    log.info(f"Signature: {signature}")
+                    log.debug(f"Signature: {signature}")
=                    for index, key in enumerate(signature.parameters):
=                        parameter = signature.parameters[key]
-                        log.info(f"Processing parameter {index} ({parameter.annotation})")
+                        log.debug(f"Processing parameter {index} ({parameter.annotation})")
=                        if parameter.annotation == inspect._empty:
-                            log.info (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
+                            log.debug (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
=                            continue 
=
-                        log.info (f"Converting argument {index} to {parameter.annotation.__name__}")
+                        log.debug (f"Converting argument {index} to {parameter.annotation.__name__}")
=                        arguments[index] = parameter.annotation(arguments[index])
=
=                    implementation(*arguments, **step)
@@ -117,7 +117,7 @@ def divide_and_verify(a: float, b: float, expected: float, **kwargs):
=
=@step("The word {0} has {1} characters")
=def verify_characters_count(word, expected_length: int, **kwargs):
-    log.info(f"Is '{word}' of length {expected_length}?")
+    log.debug(f"Is '{word}' of length {expected_length}?")
=    actual_length = len(word)
=
=    tester.assertEqual(expected_length, actual_length)

Implement code block passing

Now interpreters can access code blocks at .step.code_blocks.

index 101d5af..c347baa 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -23,6 +23,18 @@ Content outside of bullet points (like this) won't have any effect on the progra
=## Text
=
=  * The word `blocks` has `6` characters
+  * There are `3` properties in the following JSON
+  
+    This step includes a code block. All code blocks, together with their (optional) language and metadata (text following the language on the same line) will be passed to interpreters as `step.code_blocks`. It's an array, so you can have multiple blocks.
+
+    ``` json
+    {
+        "one": "Hello",
+        "two": [ 1, 2, 1, 2 ],
+        "three": null
+    }
+    ```
+
=  * There are `3` `r`s in the word `strawberry`
=  * The reverse of `abc` is `cba`
=  * The reverse of `CIA` is `KGB`
index 08a839a..f5a28bd 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -122,5 +122,13 @@ def verify_characters_count(word, expected_length: int, **kwargs):
=
=    tester.assertEqual(expected_length, actual_length)
=
-  
+
+@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}")
+    data = json.loads(code)
+    tester.assertEqual(expected_count, len(data))
+
+
=ready()
index 9da0529..8788fab 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -91,6 +91,26 @@ pub struct Step {
=
=    /// Values of the arguments
=    pub arguments: Vec<String>,
+
+    /// List of code blocks
+    pub code_blocks: Vec<CodeBlock>,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct CodeBlock {
+    value: String,
+    language: Option<String>,
+    meta: Option<String>,
+}
+
+impl From<&markdown::mdast::Code> for CodeBlock {
+    fn from(code: &markdown::mdast::Code) -> Self {
+        Self {
+            language: code.lang.to_owned(),
+            value: code.value.to_owned(),
+            meta: code.meta.to_owned(),
+        }
+    }
=}
=
=#[derive(Deserialize)]
@@ -242,11 +262,29 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=        }
=
=        // TODO: Extract list, tables, code blocks etc. from subsequent children of the list item
+        let mut code_blocks = vec![];
+
+        for child in item.children.iter() {
+            match child {
+                markdown::mdast::Node::Code(code) => {
+                    log::info!(
+                        "Found a code block: {language} ({meta})",
+                        language = code.lang.as_deref().unwrap_or("unspecified"),
+                        meta = code.meta.as_deref().unwrap_or_default(),
+                    );
+
+                    code_blocks.push(CodeBlock::from(code))
+                }
+
+                _ => continue,
+            }
+        }
=
=        Ok(Self {
=            description: headline.to_string(),
=            variant: variant_headline.to_string(),
=            arguments,
+            code_blocks,
=        })
=    }
=}

Allow interpreters to hint about failed steps

The hint is an optional field of the Failure message. It is supposed to provide advice to user about a potential solution to a problem. If present, it will be printed below the reason.

An example of hint is in the basic.py interpreter.

index f5a28bd..5b9f78a 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -1,12 +1,15 @@
=#!/usr/bin/env python3
=
-import unittest
=import inspect
=import json
=import logging
=import os
+import re
+import unittest
+from textwrap import dedent
=from typing import Any, Callable
=
+
=# Library code
=# ============
=
@@ -84,10 +87,26 @@ def ready():
=                    send({ "type": "Failure", "reason": str(error) })
=
=            else:
+                # Generate helpful hint
+                indices = re.findall(r"\{(\d)\}", variant)
+                params = map(lambda index: f"arg_{index}: str", indices)
+                name = f"step_implementation_{len(steps_implementation):02}"
+
+                hint = dedent(f"""
+                To get started, place the following in {__file__}:
+
+                ``` python
+                @step("{variant}")
+                def {name}({str.join(", ", params)}, **kwargs):
+                    assert "cat" == "dog", "Obviously you need to replace this!"
+                ```
+                """)
+
=                log.warning(f"Not implemented: {variant}")
=                send({
=                    "type": 'Failure',
-                    'reason': 'Step not implemented'
+                    "reason": "step not implemented",
+                    "hint": hint
=                })
=
=        except EOFError:
index 4eed1ef..99d105a 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -128,9 +128,9 @@ impl<'a> ScenarioReport<'a> {
=                    log::debug!("Step executed successfully: {step:#?}");
=                    *status = StepStatus::Ok;
=                }
-                InterpreterMessage::Failure { reason } => {
+                InterpreterMessage::Failure { reason, hint } => {
=                    log::debug!("Step failed:\n\n {step:#?} \n\n {reason:#?}");
-                    *status = StepStatus::Failed { reason };
+                    *status = StepStatus::Failed { reason, hint };
=
=                    // Do not run subsequent steps.
=                    //
@@ -218,7 +218,10 @@ pub struct StepReport<'a> {
=
=pub enum StepStatus {
=    Ok,
-    Failed { reason: String },
+    Failed {
+        reason: String,
+        hint: Option<String>,
+    },
=    NotEvaluated,
=}
=
@@ -271,8 +274,12 @@ impl Display for EvaluationReport<'_> {
=                        arguments = step.arguments
=                    )?;
=
-                    if let StepStatus::Failed { reason } = status {
+                    if let StepStatus::Failed { reason, hint } = status {
=                        writeln!(f, "\n      {}\n", reason.red())?;
+                        if let Some(hint) = hint {
+                            // TODO: Indent the hint properly
+                            writeln!(f, "\n\n{}\n", hint)?;
+                        }
=                    }
=                }
=            }
@@ -314,9 +321,14 @@ impl<'a> From<&'a Step> for StepReport<'a> {
=#[derive(Deserialize, Serialize, Debug)]
=#[serde(tag = "type")]
=pub enum InterpreterMessage {
-    InterpreterState { ready: bool },
+    InterpreterState {
+        ready: bool,
+    },
=    Success,
-    Failure { reason: String },
+    Failure {
+        reason: String,
+        hint: Option<String>,
+    },
=}
=
=/// Messages from this (control) program to an interpreter

Implement the remaining step variants in basic.py

Only the CIA <-> KGB step fails now, but that's intentional.

index 5b9f78a..484bdc3 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -149,5 +149,19 @@ def count_json_properties(expected_count: int, **kwargs):
=    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):
+    tester.assertAlmostEqual(expected, b - a) 
+
+
+@step("There are {0} {1}s in the word {2}")
+def step_implementation_05(count: int, letter: str, word: str, **kwargs):
+    actual_count = word.count(letter)
+    tester.assertEqual(count, actual_count)
+
+@step("The reverse of {0} is {1}")
+def step_implementation_06(word: str, reverse: str, **kwargs):
+    actual_reverse = word[::-1]
+    tester.assertEqual(reverse, actual_reverse)
=
=ready()

Improve report indentation

Make sure that multiline strings are indented correctly.

For this, I made the Indentable trait and implemented it for &str.

Also, the arguments are now printed in a dimmed style.

new file mode 100644
index 0000000..f655e1d
--- /dev/null
+++ b/src/indentable.rs
@@ -0,0 +1,120 @@
+pub trait Indentable {
+    fn indent(self, width: usize) -> String;
+}
+
+impl Indentable for &str {
+    fn indent(self, width: usize) -> String {
+        let spaces = " ".repeat(width);
+        self.replace("\r\n", "\n")
+            .split("\n")
+            .map(|line| spaces.clone() + line)
+            .reduce(|accu, line| accu + "\n" + &line)
+            .unwrap_or(spaces)
+    }
+}
+
+#[cfg(test)]
+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);
+    }
+
+    #[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 1e3dab7..b539e3c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
+mod indentable;
=mod report;
=mod spec;
=
index 99d105a..b3df963 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -1,3 +1,4 @@
+use crate::indentable::Indentable;
=use crate::spec::{Scenario, Spec, Step, Suite};
=use anyhow::{Context, anyhow};
=use colored::Colorize;
@@ -252,11 +253,20 @@ impl Display for EvaluationReport<'_> {
=                    ScenarioStatus::Pending => "?".to_string().bold().dimmed(),
=                    ScenarioStatus::FailedToRun { .. } => "x".to_string().bold().red(),
=                };
-                writeln!(f, "\n  {sigil} {title}\n", title = scenario.title)?;
+                writeln!(
+                    f,
+                    "\n{indentation}{sigil} {title}\n",
+                    indentation = "".indent(0),
+                    title = scenario.title
+                )?;
=                if let ScenarioStatus::FailedToRun { error } = status {
-                    writeln!(f, "    {}\n", error.root_cause().to_string().red())?;
+                    writeln!(
+                        f,
+                        "{cause}\n",
+                        cause = error.root_cause().to_string().red().indent(2)
+                    )?;
=                    for cause in error.chain() {
-                        writeln!(f, "      * {}", cause)?;
+                        writeln!(f, "  * {}", cause.to_string().indent(2))?
=                    }
=                    writeln!(f, "\n")?;
=                }
@@ -269,16 +279,16 @@ impl Display for EvaluationReport<'_> {
=                    };
=                    writeln!(
=                        f,
-                        "    {sigil} {description} {arguments:?}",
+                        "{indentation}{sigil} {description} {arguments}",
+                        indentation = "".indent(2),
=                        description = step.description,
-                        arguments = step.arguments
+                        arguments = format!("{:?}", step.arguments).dimmed()
=                    )?;
=
=                    if let StepStatus::Failed { reason, hint } = status {
-                        writeln!(f, "\n      {}\n", reason.red())?;
+                        writeln!(f, "\n{}\n", reason.indent(4).red())?;
=                        if let Some(hint) = hint {
-                            // TODO: Indent the hint properly
-                            writeln!(f, "\n\n{}\n", hint)?;
+                            writeln!(f, "{}\n", hint.as_str().indent(4))?;
=                        }
=                    }
=                }

Bump minor version

To celebrate the code blocks feature.

index 922de52..c2e992f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ dependencies = [
=
=[[package]]
=name = "tad-better-behavior"
-version = "0.1.2"
+version = "0.2.0"
=dependencies = [
= "anyhow",
= "clap",
index b7c5dcd..9297cf6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
=[package]
=name = "tad-better-behavior"
-version = "0.1.2"
+version = "0.2.0"
=edition = "2024"
=
=[dependencies]