Skip to main content

pepl_eval/
test_runner.rs

1//! PEPL test runner — executes `tests { }` blocks from PEPL programs.
2//!
3//! Each test case creates a fresh SpaceInstance and dispatches actions
4//! by calling them as functions. `with_responses { }` provides mock
5//! capability call results.
6
7use crate::error::{EvalError, EvalResult};
8use crate::space::SpaceInstance;
9use pepl_stdlib::Value;
10use pepl_types::ast::*;
11
12/// Result of running a single test case.
13#[derive(Debug, Clone)]
14pub struct TestResult {
15    /// Test description (from `test "description" { ... }`).
16    pub description: String,
17    /// Whether the test passed.
18    pub passed: bool,
19    /// Error message if the test failed.
20    pub error: Option<String>,
21}
22
23impl std::fmt::Display for TestResult {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        if self.passed {
26            write!(f, "  ✓ {}", self.description)
27        } else {
28            write!(
29                f,
30                "  ✗ {} — {}",
31                self.description,
32                self.error.as_deref().unwrap_or("unknown error")
33            )
34        }
35    }
36}
37
38/// Summary of running all test blocks.
39#[derive(Debug)]
40pub struct TestRunSummary {
41    pub results: Vec<TestResult>,
42    pub passed: usize,
43    pub failed: usize,
44}
45
46impl std::fmt::Display for TestRunSummary {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        for r in &self.results {
49            writeln!(f, "{r}")?;
50        }
51        writeln!(f, "\n{} passed, {} failed", self.passed, self.failed)
52    }
53}
54
55/// A mocked capability response: (module, function) → response Value.
56#[derive(Debug, Clone)]
57pub struct MockResponse {
58    pub module: String,
59    pub function: String,
60    pub response: Value,
61}
62
63/// Run all test blocks in a PEPL program.
64///
65/// Each test case gets a fresh `SpaceInstance`. Actions are dispatched
66/// by executing test body statements that call actions as functions.
67pub fn run_tests(program: &Program) -> EvalResult<TestRunSummary> {
68    let mut results = Vec::new();
69
70    for test_block in &program.tests {
71        for case in &test_block.cases {
72            let result = run_single_test(program, case)?;
73            results.push(result);
74        }
75    }
76
77    let passed = results.iter().filter(|r| r.passed).count();
78    let failed = results.iter().filter(|r| !r.passed).count();
79
80    Ok(TestRunSummary {
81        results,
82        passed,
83        failed,
84    })
85}
86
87/// Run a single test case with a fresh SpaceInstance.
88fn run_single_test(program: &Program, case: &TestCase) -> EvalResult<TestResult> {
89    // Resolve mock responses from `with_responses` block
90    let mocks = resolve_mocks(program, case)?;
91
92    // Create a fresh space instance for this test
93    let mut instance = SpaceInstance::new(program)?;
94
95    // Install mock responses
96    if !mocks.is_empty() {
97        instance.set_mock_responses(mocks);
98    }
99
100    // Execute the test body — statements that dispatch actions and check assertions
101    let exec_result = execute_test_body(&mut instance, &case.body, &program.space.body);
102
103    match exec_result {
104        Ok(()) => Ok(TestResult {
105            description: case.description.clone(),
106            passed: true,
107            error: None,
108        }),
109        Err(EvalError::AssertionFailed(msg)) => Ok(TestResult {
110            description: case.description.clone(),
111            passed: false,
112            error: Some(msg),
113        }),
114        Err(e) => Ok(TestResult {
115            description: case.description.clone(),
116            passed: false,
117            error: Some(format!("{e}")),
118        }),
119    }
120}
121
122/// Resolve `with_responses { ... }` into mock capability responses.
123fn resolve_mocks(program: &Program, case: &TestCase) -> EvalResult<Vec<MockResponse>> {
124    let mut mocks = Vec::new();
125
126    if let Some(with_responses) = &case.with_responses {
127        // Create a temporary evaluator for evaluating response expressions
128        let mut temp_instance = SpaceInstance::new(program)?;
129
130        for mapping in &with_responses.mappings {
131            let response = temp_instance.eval_expr_public(&mapping.response)?;
132            mocks.push(MockResponse {
133                module: mapping.module.name.clone(),
134                function: mapping.function.name.clone(),
135                response,
136            });
137        }
138    }
139
140    Ok(mocks)
141}
142
143/// Execute the body of a test case.
144///
145/// In test bodies, unqualified function calls dispatch actions:
146///   `increment()` → dispatches the `increment` action
147///   `add_todo()` → dispatches the `add_todo` action
148///
149/// Statements like `assert`, `let`, `if`, `for`, `match` work as normal.
150fn execute_test_body(
151    instance: &mut SpaceInstance,
152    body: &Block,
153    space_body: &SpaceBody,
154) -> EvalResult<()> {
155    for stmt in &body.stmts {
156        execute_test_stmt(instance, stmt, space_body)?;
157    }
158    Ok(())
159}
160
161/// Execute a single statement in test context.
162fn execute_test_stmt(
163    instance: &mut SpaceInstance,
164    stmt: &Stmt,
165    space_body: &SpaceBody,
166) -> EvalResult<()> {
167    match stmt {
168        Stmt::Expr(expr_stmt) => {
169            execute_test_expr(instance, &expr_stmt.expr, space_body)?;
170            Ok(())
171        }
172        Stmt::Assert(assert) => {
173            // Evaluate the assertion condition using the space's evaluator
174            let val = instance.eval_expr_public(&assert.condition)?;
175            if !val.is_truthy() {
176                let msg = assert
177                    .message
178                    .clone()
179                    .unwrap_or_else(|| "assertion failed".into());
180                return Err(EvalError::AssertionFailed(msg));
181            }
182            Ok(())
183        }
184        Stmt::Let(binding) => {
185            let value = instance.eval_expr_public(&binding.value)?;
186            if let Some(name) = &binding.name {
187                instance.define_in_env(&name.name, value);
188            }
189            Ok(())
190        }
191        Stmt::If(if_expr) => {
192            let cond = instance.eval_expr_public(&if_expr.condition)?;
193            if cond.is_truthy() {
194                execute_test_body(instance, &if_expr.then_block, space_body)?;
195            } else if let Some(else_branch) = &if_expr.else_branch {
196                match else_branch {
197                    ElseBranch::ElseIf(elif) => {
198                        let cond = instance.eval_expr_public(&elif.condition)?;
199                        if cond.is_truthy() {
200                            execute_test_body(instance, &elif.then_block, space_body)?;
201                        }
202                    }
203                    ElseBranch::Block(block) => {
204                        execute_test_body(instance, block, space_body)?;
205                    }
206                }
207            }
208            Ok(())
209        }
210        Stmt::For(for_expr) => {
211            let iterable = instance.eval_expr_public(&for_expr.iterable)?;
212            if let Value::List(items) = iterable {
213                for (i, item) in items.iter().enumerate() {
214                    instance.push_scope();
215                    instance.define_in_env(&for_expr.item.name, item.clone());
216                    if let Some(idx) = &for_expr.index {
217                        instance.define_in_env(&idx.name, Value::Number(i as f64));
218                    }
219                    execute_test_body(instance, &for_expr.body, space_body)?;
220                    instance.pop_scope();
221                }
222            }
223            Ok(())
224        }
225        _ => {
226            // Other statements (set, match, return) — evaluate normally
227            instance.eval_stmt_public(stmt)?;
228            Ok(())
229        }
230    }
231}
232
233/// Execute an expression in test context.
234///
235/// Unqualified calls are dispatched as actions.
236fn execute_test_expr(
237    instance: &mut SpaceInstance,
238    expr: &Expr,
239    space_body: &SpaceBody,
240) -> EvalResult<Value> {
241    match &expr.kind {
242        ExprKind::Call { name, args } => {
243            // Check if this is an action dispatch
244            let is_action = space_body.actions.iter().any(|a| a.name.name == name.name);
245
246            if is_action {
247                let mut arg_vals = Vec::new();
248                for arg in args {
249                    arg_vals.push(instance.eval_expr_public(arg)?);
250                }
251                let result = instance.dispatch(&name.name, arg_vals)?;
252                if !result.committed {
253                    if let Some(err) = result.invariant_error {
254                        return Err(EvalError::InvariantViolation(err));
255                    }
256                }
257                Ok(Value::Nil)
258            } else {
259                instance.eval_expr_public(expr)
260            }
261        }
262        _ => instance.eval_expr_public(expr),
263    }
264}