This repository has been archived on 2026-08-20. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
el-retired/_archive/rust-bootstrap/engrams/el-test/src/runner.rs
T

383 lines
13 KiB
Rust

//! 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<TestResult> {
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<TestResult> {
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<TestResult> {
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<AssertionResult> = Vec::new();
let mut error: Option<String> = 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<Stmt>) -> 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);
}
}