//! Test runner — executes test cases and produces results. use std::time::Instant; use el_parser::{SeedStmt, Stmt}; use crate::eval::{evaluate_assert, expr_to_text, Evaluator}; use crate::graph::TestGraph; use crate::types::{AssertionResult, TestCase, TestResult, TestStatus, TestTarget}; /// Runs test cases and collects results. pub struct TestRunner; impl TestRunner { pub fn new() -> Self { Self } /// Run all tests. E2e tests are skipped if `engram_url` is `None`. pub fn run_all(&self, tests: &[TestCase], engram_url: Option<&str>) -> Vec { let mut results = Vec::new(); for test in tests { match &test.target { TestTarget::Unit => { results.push(self.run_unit_test(test)); } TestTarget::E2e => { if let Some(url) = engram_url { results.push(self.run_e2e_test(test, url)); } else { results.push(TestResult { name: test.name.clone(), target: TestTarget::E2e, status: TestStatus::Skip, duration_ms: 0, assertions: vec![], error: Some("ENGRAM_URL not set; skipping e2e test".into()), }); } } TestTarget::Both => { results.push(self.run_unit_test(test)); if let Some(url) = engram_url { results.push(self.run_e2e_test(test, url)); } else { results.push(TestResult { name: format!("{} (e2e)", test.name), target: TestTarget::E2e, status: TestStatus::Skip, duration_ms: 0, assertions: vec![], error: Some("ENGRAM_URL not set; skipping e2e test".into()), }); } } } } results } /// Run only unit tests. pub fn run_unit(&self, tests: &[TestCase]) -> Vec { tests .iter() .filter(|t| matches!(t.target, TestTarget::Unit | TestTarget::Both)) .map(|t| self.run_unit_test(t)) .collect() } /// Run only e2e tests. pub fn run_e2e(&self, tests: &[TestCase], engram_url: &str) -> Vec { tests .iter() .filter(|t| matches!(t.target, TestTarget::E2e | TestTarget::Both)) .map(|t| self.run_e2e_test(t, engram_url)) .collect() } /// Run a single test against an in-memory graph. pub fn run_one(&self, test: &TestCase, engram_url: Option<&str>) -> TestResult { match (&test.target, engram_url) { (TestTarget::E2e, Some(url)) => self.run_e2e_test(test, url), (TestTarget::E2e, None) => TestResult { name: test.name.clone(), target: TestTarget::E2e, status: TestStatus::Skip, duration_ms: 0, assertions: vec![], error: Some("ENGRAM_URL not set; skipping e2e test".into()), }, _ => self.run_unit_test(test), } } // ── Private ─────────────────────────────────────────────────────────────── fn run_unit_test(&self, test: &TestCase) -> TestResult { let start = Instant::now(); let mut graph = TestGraph::new(); // Apply seed statements first for stmt in &test.body { if let Stmt::Seed(seed, _) = stmt { apply_seed(&mut graph, seed); } } self.execute_test(test, &graph, TestTarget::Unit, start) } fn run_e2e_test(&self, test: &TestCase, _engram_url: &str) -> TestResult { let start = Instant::now(); // For e2e, we still use an in-memory graph for now (real DB client TBD). // The distinction is that e2e tests skip the seed step (they use real data). let graph = TestGraph::new(); self.execute_test(test, &graph, TestTarget::E2e, start) } fn execute_test( &self, test: &TestCase, graph: &TestGraph, target: TestTarget, start: Instant, ) -> TestResult { let mut eval = Evaluator::new(graph); let mut assertions: Vec = Vec::new(); let mut error: Option = None; for stmt in &test.body { match stmt { Stmt::Seed(..) => { // Already processed before eval } Stmt::Assert(expr, _) => { let text = expr_to_text(expr); let result = evaluate_assert(&mut eval, expr, &text); assertions.push(result); } other => { if let Err(e) = eval.exec_stmt(other) { error = Some(e); break; } } } } let duration_ms = start.elapsed().as_millis() as u64; let all_passed = assertions.iter().all(|a| a.passed); let status = if error.is_some() { TestStatus::Error } else if all_passed { TestStatus::Pass } else { TestStatus::Fail }; TestResult { name: test.name.clone(), target, status, duration_ms, assertions, error, } } } impl Default for TestRunner { fn default() -> Self { Self::new() } } fn apply_seed(graph: &mut TestGraph, seed: &SeedStmt) { match seed { SeedStmt::Node { node_type, content, importance, tier } => { graph.seed_node(node_type, content, *importance, tier.as_deref()); } SeedStmt::Edge { from, to, relation, weight } => { // For edges we use string IDs — in a real impl these would be looked // up from the graph's node registry. We create placeholder node IDs. use crate::graph::NodeId; graph.seed_edge( NodeId(from.clone()), NodeId(to.clone()), relation, *weight, ); } } } #[cfg(test)] mod tests { use super::*; use el_parser::{Expr, Literal, BinOp, Stmt}; use el_lexer::Span; fn dummy_span() -> Span { Span::new(0, 0, 1, 1) } fn make_assert(expr: Expr) -> Stmt { Stmt::Assert(expr, dummy_span()) } fn make_let(name: &str, expr: Expr) -> Stmt { Stmt::Let { name: name.to_string(), type_ann: None, value: expr, span: dummy_span(), } } fn int_lit(n: i64) -> Expr { Expr::Literal(Literal::Int(n)) } fn str_lit(s: &str) -> Expr { Expr::Literal(Literal::Str(s.to_string())) } fn bool_lit(b: bool) -> Expr { Expr::Literal(Literal::Bool(b)) } fn binop(op: BinOp, l: Expr, r: Expr) -> Expr { Expr::BinOp { op, left: Box::new(l), right: Box::new(r) } } fn test_case(name: &str, body: Vec) -> TestCase { TestCase { name: name.to_string(), target: TestTarget::Unit, body, } } #[test] fn test_runner_pass() { let runner = TestRunner::new(); let tc = test_case("arithmetic", vec![ make_let("x", int_lit(6)), make_let("y", int_lit(7)), make_let("result", binop(BinOp::Mul, Expr::Ident("x".into()), Expr::Ident("y".into()))), make_assert(binop(BinOp::Eq, Expr::Ident("result".into()), int_lit(42))), ]); let result = runner.run_one(&tc, None); assert_eq!(result.status, TestStatus::Pass); assert!(result.assertions[0].passed); } #[test] fn test_runner_fail() { let runner = TestRunner::new(); let tc = test_case("failing", vec![ make_assert(binop(BinOp::Eq, int_lit(1), int_lit(2))), ]); let result = runner.run_one(&tc, None); assert_eq!(result.status, TestStatus::Fail); assert!(!result.assertions[0].passed); } #[test] fn test_runner_multiple_assertions_partial_fail() { let runner = TestRunner::new(); let tc = test_case("partial", vec![ make_assert(bool_lit(true)), make_assert(binop(BinOp::Eq, int_lit(1), int_lit(2))), // fails make_assert(bool_lit(true)), ]); let result = runner.run_one(&tc, None); assert_eq!(result.status, TestStatus::Fail); assert!(result.assertions[0].passed); assert!(!result.assertions[1].passed); assert!(result.assertions[2].passed); } #[test] fn test_runner_e2e_skip_without_url() { let runner = TestRunner::new(); let tc = TestCase { name: "e2e test".to_string(), target: TestTarget::E2e, body: vec![], }; let result = runner.run_one(&tc, None); assert_eq!(result.status, TestStatus::Skip); } #[test] fn test_runner_run_all_skips_e2e() { let runner = TestRunner::new(); let tests = vec![ TestCase { name: "unit".into(), target: TestTarget::Unit, body: vec![] }, TestCase { name: "e2e".into(), target: TestTarget::E2e, body: vec![] }, ]; let results = runner.run_all(&tests, None); assert_eq!(results.len(), 2); assert_eq!(results[0].status, TestStatus::Pass); // empty = pass assert_eq!(results[1].status, TestStatus::Skip); } #[test] fn test_runner_run_unit_filters_unit_only() { let runner = TestRunner::new(); let tests = vec![ TestCase { name: "unit".into(), target: TestTarget::Unit, body: vec![] }, TestCase { name: "e2e".into(), target: TestTarget::E2e, body: vec![] }, ]; let results = runner.run_unit(&tests); assert_eq!(results.len(), 1); assert_eq!(results[0].name, "unit"); } #[test] fn test_runner_empty_test_passes() { let runner = TestRunner::new(); let tc = test_case("empty", vec![]); let result = runner.run_one(&tc, None); assert_eq!(result.status, TestStatus::Pass); } #[test] fn test_runner_with_seed_and_activate() { use el_parser::SeedStmt; let runner = TestRunner::new(); let seed = Stmt::Seed( SeedStmt::Node { node_type: "Customer".into(), content: "Will Anderson, founding member".into(), importance: 0.9, tier: Some("Semantic".into()), }, dummy_span(), ); // activate Customer where "founding" let activate = Expr::Activate { type_name: "Customer".into(), query: "founding".into(), }; let let_results = make_let("results", activate); // assert results.len() > 0 => assert results.len() > 0 // We'll call .len() via a Call to Field let len_call = Expr::Call { func: Box::new(Expr::Field { object: Box::new(Expr::Ident("results".into())), field: "len".into(), }), args: vec![], }; let assert_len = make_assert(binop(BinOp::Gt, len_call, int_lit(0))); let tc = test_case("seed and activate", vec![seed, let_results, assert_len]); let result = runner.run_one(&tc, None); assert_eq!(result.status, TestStatus::Pass); } #[test] fn test_runner_empty_graph_activate_returns_empty() { let runner = TestRunner::new(); // No seeds — activate should return empty list let activate = Expr::Activate { type_name: "Customer".into(), query: "anything".into(), }; let let_results = make_let("results", activate); let len_call = Expr::Call { func: Box::new(Expr::Field { object: Box::new(Expr::Ident("results".into())), field: "len".into(), }), args: vec![], }; let assert_zero = make_assert(binop(BinOp::Eq, len_call, int_lit(0))); let tc = test_case("empty graph", vec![let_results, assert_zero]); let result = runner.run_one(&tc, None); assert_eq!(result.status, TestStatus::Pass); } }