Archived
Archive Rust bootstrap — El compiler is now self-hosting
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user