Archived
383 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|