rename crates/ to engrams/; add el-compiler el package with bootstrap artifact

- crates/ → engrams/ (Rust engrams live here)
- el-compiler/ added: el self-hosting compiler as an el package
  - src/{compiler,lexer,parser,codegen}.el
  - bootstrap/el-compiler.elc (114KB, Rust-compiled seed)
- el.toml Cargo.toml workspace paths updated
- neuron-rs cross-repo path deps fixed (were pointing to products/ instead of foundation/)
This commit is contained in:
Will Anderson
2026-04-29 03:27:32 -05:00
parent 19ed2721ee
commit a42429012e
120 changed files with 3836 additions and 64 deletions
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "el-test"
description = "Engram language unified testing framework — unit and e2e same syntax, seed-based graph testing"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
el-lexer = { workspace = true }
el-parser = { workspace = true }
el-types = { workspace = true }
el-compiler = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+138
View File
@@ -0,0 +1,138 @@
//! Test discovery — finds all `test` blocks in an `.el` source file.
use el_lexer::tokenize;
use el_parser::{parse, Stmt};
use crate::types::{TestCase, TestTarget};
/// Parse a source string and extract all `test` block definitions.
///
/// Returns an error if the source cannot be lexed or parsed.
pub fn discover(source: &str) -> Result<Vec<TestCase>, String> {
let tokens = tokenize(source).map_err(|e| format!("lex error: {e}"))?;
let program = parse(tokens, source.to_string()).map_err(|e| format!("parse error: {e}"))?;
let mut cases = Vec::new();
collect_tests(&program.stmts, &mut cases);
Ok(cases)
}
fn collect_tests(stmts: &[Stmt], out: &mut Vec<TestCase>) {
for stmt in stmts {
if let Stmt::TestDef { name, target, body, .. } = stmt {
out.push(TestCase {
name: name.clone(),
target: TestTarget::from(target.clone()),
body: body.clone(),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discover_empty_source() {
let cases = discover("").unwrap();
assert!(cases.is_empty());
}
#[test]
fn test_discover_no_tests() {
let src = r#"let x: Int = 42"#;
let cases = discover(src).unwrap();
assert!(cases.is_empty());
}
#[test]
fn test_discover_single_test() {
let src = r#"
test "basic arithmetic" {
let x: Int = 6
let y: Int = 7
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases.len(), 1);
assert_eq!(cases[0].name, "basic arithmetic");
assert_eq!(cases[0].target, TestTarget::Unit);
}
#[test]
fn test_discover_multiple_tests() {
let src = r#"
test "test one" {
let x: Int = 1
}
test "test two" {
let y: Int = 2
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases.len(), 2);
assert_eq!(cases[0].name, "test one");
assert_eq!(cases[1].name, "test two");
}
#[test]
fn test_discover_e2e_target() {
let src = r#"
test "production lookup" target: e2e {
let x: Int = 1
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases.len(), 1);
assert_eq!(cases[0].target, TestTarget::E2e);
}
#[test]
fn test_discover_both_target() {
let src = r#"
test "dual target" target: both {
let x: Int = 1
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases[0].target, TestTarget::Both);
}
#[test]
fn test_discover_default_target_is_unit() {
let src = r#"test "no target" { let x: Int = 1 }"#;
let cases = discover(src).unwrap();
assert_eq!(cases[0].target, TestTarget::Unit);
}
#[test]
fn test_discover_mixed_stmts() {
let src = r#"
let x: Int = 42
test "my test" {
let y: Int = x
}
fn helper() -> Int { return 1 }
"#;
let cases = discover(src).unwrap();
assert_eq!(cases.len(), 1);
}
#[test]
fn test_discover_lex_error() {
let result = discover(r#""unterminated"#);
assert!(result.is_err());
}
#[test]
fn test_discover_body_preserved() {
let src = r#"
test "body check" {
let x: Int = 1
let y: Int = 2
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases[0].body.len(), 2);
}
}
+562
View File
@@ -0,0 +1,562 @@
//! Test expression evaluator.
//!
//! Walks AST expressions inside a test block and produces runtime `EvalValue`s.
//! This is a lightweight direct evaluator (not the full bytecode VM) specifically
//! designed for the simple expression patterns that appear in test assertions.
use std::collections::HashMap;
use el_parser::{BinOp, Expr, Literal, Stmt};
use crate::graph::{ActivatedNode, ReasoningResult, TestGraph};
use crate::types::AssertionResult;
/// A runtime value in the test evaluator.
#[derive(Debug, Clone, PartialEq)]
pub enum EvalValue {
Int(i64),
Float(f64),
Str(String),
Bool(bool),
Nil,
/// A list of activated graph nodes (result of `activate`)
NodeList(Vec<ActivatedNode>),
/// Result of a `reason()` call
Reasoning(ReasoningResultValue),
}
/// Simplified reasoning result value (owns the verdict string for comparison).
#[derive(Debug, Clone, PartialEq)]
pub struct ReasoningResultValue {
pub verdict: String,
pub confidence: f32,
}
impl EvalValue {
/// Try to get the length of a list/string value.
pub fn len(&self) -> Option<i64> {
match self {
EvalValue::NodeList(v) => Some(v.len() as i64),
EvalValue::Str(s) => Some(s.len() as i64),
_ => None,
}
}
/// Try to index into a list.
pub fn index(&self, i: i64) -> Option<EvalValue> {
match self {
EvalValue::NodeList(v) => {
let idx = if i < 0 {
let uidx = (-i) as usize;
v.len().checked_sub(uidx)?
} else {
i as usize
};
let node = v.get(idx)?;
// Return the node as a struct-like value — we special-case field access
Some(EvalValue::NodeList(vec![node.clone()]))
}
_ => None,
}
}
/// Check if this value contains a substring (for string `contains` keyword).
pub fn contains_str(&self, needle: &str) -> bool {
match self {
EvalValue::Str(s) => s.contains(needle),
EvalValue::NodeList(v) => v.iter().any(|n| n.content.contains(needle)),
_ => false,
}
}
pub fn as_bool(&self) -> bool {
match self {
EvalValue::Bool(b) => *b,
EvalValue::Nil => false,
_ => true,
}
}
}
impl std::fmt::Display for EvalValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EvalValue::Int(n) => write!(f, "{n}"),
EvalValue::Float(n) => write!(f, "{n}"),
EvalValue::Str(s) => write!(f, "{s}"),
EvalValue::Bool(b) => write!(f, "{b}"),
EvalValue::Nil => write!(f, "nil"),
EvalValue::NodeList(v) => write!(f, "[{} node(s)]", v.len()),
EvalValue::Reasoning(r) => write!(f, "ReasoningResult {{ verdict: {} }}", r.verdict),
}
}
}
/// Evaluator context — holds local bindings and the graph.
pub struct Evaluator<'g> {
locals: HashMap<String, EvalValue>,
graph: &'g TestGraph,
}
impl<'g> Evaluator<'g> {
pub fn new(graph: &'g TestGraph) -> Self {
Self {
locals: HashMap::new(),
graph,
}
}
/// Execute a statement. Returns `Ok(())` or an error string.
pub fn exec_stmt(&mut self, stmt: &Stmt) -> Result<(), String> {
match stmt {
Stmt::Let { name, value, .. } => {
let v = self.eval_expr(value)?;
self.locals.insert(name.clone(), v);
}
Stmt::Expr(expr, _) => {
self.eval_expr(expr)?;
}
Stmt::Return(..) => {
// Return from test body — treat as no-op continuation
}
// Seed statements are handled before eval in the runner
Stmt::Seed(..) | Stmt::TestDef { .. } | Stmt::Assert(..) => {}
Stmt::FnDef { .. } | Stmt::TypeDef { .. } | Stmt::EnumDef { .. } => {}
// New statement kinds — skip
_ => {}
}
Ok(())
}
/// Evaluate an expression, returning its value.
pub fn eval_expr(&mut self, expr: &Expr) -> Result<EvalValue, String> {
match expr {
Expr::Literal(lit) => Ok(match lit {
Literal::Int(n) => EvalValue::Int(*n),
Literal::Float(f) => EvalValue::Float(*f),
Literal::Str(s) => EvalValue::Str(s.clone()),
Literal::Bool(b) => EvalValue::Bool(*b),
}),
Expr::Ident(name) => {
self.locals.get(name).cloned().ok_or_else(|| format!("undefined variable '{name}'"))
}
Expr::BinOp { op, left, right } => {
// Special: handle `<expr> contains <str>` — parsed as BinOp in the `contains` handler
// Actually `contains` is a keyword identifier parsed as Ident in the postfix context.
// We handle it as a Call pattern below.
let lv = self.eval_expr(left)?;
let rv = self.eval_expr(right)?;
self.eval_binop(op, lv, rv)
}
Expr::Call { func, args } => {
match func.as_ref() {
// reason("hypothesis") — built-in
Expr::Ident(name) if name == "reason" => {
let arg = args.first().ok_or("reason() requires one argument")?;
let hypothesis = match self.eval_expr(arg)? {
EvalValue::Str(s) => s,
other => return Err(format!("reason() expects a string, got {other}")),
};
let result: ReasoningResult = self.graph.reason(&hypothesis);
Ok(EvalValue::Reasoning(ReasoningResultValue {
verdict: result.verdict.to_string(),
confidence: result.confidence,
}))
}
// results.len() — method call on a local
Expr::Field { object, field } if field == "len" => {
let obj = self.eval_expr(object)?;
match obj.len() {
Some(n) => Ok(EvalValue::Int(n)),
None => Err(format!("cannot call .len() on {obj}")),
}
}
// results[0].content contains "needle"
// `contains` is parsed as an Ident called as a method
Expr::Field { object, field } if field == "contains" => {
let obj = self.eval_expr(object)?;
let needle_arg = args.first().ok_or("contains() requires one argument")?;
let needle = match self.eval_expr(needle_arg)? {
EvalValue::Str(s) => s,
other => return Err(format!("contains() expects a string, got {other}")),
};
Ok(EvalValue::Bool(obj.contains_str(&needle)))
}
_ => {
// Unknown call — return nil
Ok(EvalValue::Nil)
}
}
}
Expr::Field { object, field } => {
let obj = self.eval_expr(object)?;
match &obj {
EvalValue::NodeList(nodes) if nodes.len() == 1 => {
let node = &nodes[0];
match field.as_str() {
"content" => Ok(EvalValue::Str(node.content.clone())),
"node_type" => Ok(EvalValue::Str(node.node_type.clone())),
"importance" => Ok(EvalValue::Float(node.importance as f64)),
"id" => Ok(EvalValue::Str(node.id.to_string())),
other => Err(format!("no field '{other}' on ActivatedNode")),
}
}
EvalValue::Reasoning(r) => match field.as_str() {
"verdict" => Ok(EvalValue::Str(r.verdict.clone())),
"confidence" => Ok(EvalValue::Float(r.confidence as f64)),
other => Err(format!("no field '{other}' on ReasoningResult")),
},
_ => Err(format!("cannot access field '{field}' on {obj}")),
}
}
Expr::Index { object, index } => {
let obj = self.eval_expr(object)?;
let idx = self.eval_expr(index)?;
let i = match idx {
EvalValue::Int(n) => n,
other => return Err(format!("index must be Int, got {other}")),
};
obj.index(i).ok_or_else(|| format!("index {i} out of bounds"))
}
Expr::Activate { type_name, query } => {
let nodes = self.graph.activate(query, Some(type_name));
Ok(EvalValue::NodeList(nodes))
}
Expr::UnaryNot(inner) => {
let v = self.eval_expr(inner)?;
Ok(EvalValue::Bool(!v.as_bool()))
}
Expr::Block(stmts) => {
for s in stmts {
self.exec_stmt(s)?;
}
Ok(EvalValue::Nil)
}
Expr::If { cond, then, else_ } => {
let cv = self.eval_expr(cond)?;
if cv.as_bool() {
self.eval_expr(then)
} else if let Some(e) = else_ {
self.eval_expr(e)
} else {
Ok(EvalValue::Nil)
}
}
Expr::Array(elems) => {
// For simplicity — arrays of primitives in tests
let mut vals = Vec::new();
for e in elems {
vals.push(self.eval_expr(e)?);
}
// Return first if all same, else nil
Ok(EvalValue::Nil)
}
Expr::Path { segments } => {
// Enum variant reference — return as string (e.g. "Insufficient")
Ok(EvalValue::Str(segments.last().cloned().unwrap_or_default()))
}
Expr::Match { subject, arms } => {
let sv = self.eval_expr(subject)?;
for arm in arms {
// Simplified: compare subject to pattern
let matches = match &arm.pattern {
el_parser::Pattern::Wildcard => true,
el_parser::Pattern::Literal(lit) => {
let lv = match lit {
Literal::Int(n) => EvalValue::Int(*n),
Literal::Float(f) => EvalValue::Float(*f),
Literal::Str(s) => EvalValue::Str(s.clone()),
Literal::Bool(b) => EvalValue::Bool(*b),
};
sv == lv
}
el_parser::Pattern::Binding(name) => {
self.locals.insert(name.clone(), sv.clone());
true
}
el_parser::Pattern::EnumVariant { variant, .. } => {
sv == EvalValue::Str(variant.clone())
}
};
if matches {
return self.eval_expr(&arm.body);
}
}
Ok(EvalValue::Nil)
}
Expr::Sealed(stmts) => {
for s in stmts {
self.exec_stmt(s)?;
}
Ok(EvalValue::Nil)
}
Expr::StructLit { type_name: _, fields, .. } => {
// Evaluate all fields but return Nil — struct construction in test
// eval context is not supported yet (tests use activate, not literals)
for (_, e) in fields {
self.eval_expr(e)?;
}
Ok(EvalValue::Nil)
}
// New expression kinds — return Nil
_ => {
Ok(EvalValue::Nil)
}
}
}
fn eval_binop(&self, op: &BinOp, lv: EvalValue, rv: EvalValue) -> Result<EvalValue, String> {
match op {
BinOp::Add => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a + b)),
(EvalValue::Float(a), EvalValue::Float(b)) => Ok(EvalValue::Float(a + b)),
(EvalValue::Str(a), EvalValue::Str(b)) => Ok(EvalValue::Str(a + &b)),
(a, b) => Err(format!("cannot add {a} and {b}")),
},
BinOp::Sub => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a - b)),
(EvalValue::Float(a), EvalValue::Float(b)) => Ok(EvalValue::Float(a - b)),
(a, b) => Err(format!("cannot subtract {a} and {b}")),
},
BinOp::Mul => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a * b)),
(EvalValue::Float(a), EvalValue::Float(b)) => Ok(EvalValue::Float(a * b)),
(a, b) => Err(format!("cannot multiply {a} and {b}")),
},
BinOp::Div => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) if b != 0 => Ok(EvalValue::Int(a / b)),
(EvalValue::Float(a), EvalValue::Float(b)) => Ok(EvalValue::Float(a / b)),
(_, EvalValue::Int(0)) => Err("division by zero".into()),
(a, b) => Err(format!("cannot divide {a} and {b}")),
},
BinOp::Eq => Ok(EvalValue::Bool(lv == rv)),
BinOp::NotEq => Ok(EvalValue::Bool(lv != rv)),
BinOp::Lt => self.compare_ord(lv, rv, |o| o == std::cmp::Ordering::Less),
BinOp::Gt => self.compare_ord(lv, rv, |o| o == std::cmp::Ordering::Greater),
BinOp::LtEq => self.compare_ord(lv, rv, |o| o != std::cmp::Ordering::Greater),
BinOp::GtEq => self.compare_ord(lv, rv, |o| o != std::cmp::Ordering::Less),
BinOp::And => Ok(EvalValue::Bool(lv.as_bool() && rv.as_bool())),
BinOp::Or => Ok(EvalValue::Bool(lv.as_bool() || rv.as_bool())),
}
}
fn compare_ord<F>(&self, lv: EvalValue, rv: EvalValue, pred: F) -> Result<EvalValue, String>
where
F: Fn(std::cmp::Ordering) -> bool,
{
let ord = match (&lv, &rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => a.cmp(b),
(EvalValue::Float(a), EvalValue::Float(b)) => {
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
}
_ => return Err(format!("cannot compare {lv} and {rv}")),
};
Ok(EvalValue::Bool(pred(ord)))
}
}
/// Evaluate a single `assert` expression and return an `AssertionResult`.
///
/// The `expr_text` is the source text representation of the assertion (for
/// diagnostic output).
pub fn evaluate_assert(
eval: &mut Evaluator,
expr: &Expr,
expr_text: &str,
) -> AssertionResult {
match eval.eval_expr(expr) {
Ok(val) => {
let passed = val.as_bool();
AssertionResult {
expression: expr_text.to_string(),
passed,
actual: Some(val.to_string()),
expected: None,
}
}
Err(_e) => AssertionResult {
expression: expr_text.to_string(),
passed: false,
actual: None,
expected: None,
}
}
}
/// Render an expression as a human-readable string (best-effort, for display).
pub fn expr_to_text(expr: &Expr) -> String {
match expr {
Expr::Literal(Literal::Int(n)) => n.to_string(),
Expr::Literal(Literal::Float(f)) => f.to_string(),
Expr::Literal(Literal::Str(s)) => format!("\"{s}\""),
Expr::Literal(Literal::Bool(b)) => b.to_string(),
Expr::Ident(name) => name.clone(),
Expr::BinOp { op, left, right } => {
let op_str = match op {
BinOp::Add => "+", BinOp::Sub => "-", BinOp::Mul => "*", BinOp::Div => "/",
BinOp::Eq => "==", BinOp::NotEq => "!=",
BinOp::Lt => "<", BinOp::Gt => ">", BinOp::LtEq => "<=", BinOp::GtEq => ">=",
BinOp::And => "&&", BinOp::Or => "||",
};
format!("{} {op_str} {}", expr_to_text(left), expr_to_text(right))
}
Expr::Field { object, field } => format!("{}.{field}", expr_to_text(object)),
Expr::Call { func, args } => {
let args_str: Vec<_> = args.iter().map(expr_to_text).collect();
format!("{}({})", expr_to_text(func), args_str.join(", "))
}
Expr::Index { object, index } => format!("{}[{}]", expr_to_text(object), expr_to_text(index)),
Expr::Activate { type_name, query } => format!("activate {type_name} where \"{query}\""),
Expr::UnaryNot(inner) => format!("!{}", expr_to_text(inner)),
Expr::Path { segments } => segments.join("::"),
_ => "<expr>".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::TestGraph;
fn fresh_eval(g: &TestGraph) -> Evaluator {
Evaluator::new(g)
}
#[test]
fn test_eval_int_literal() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Literal(Literal::Int(42));
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Int(42));
}
#[test]
fn test_eval_bool_literal() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Literal(Literal::Bool(true));
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Bool(true));
}
#[test]
fn test_eval_string_literal() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Literal(Literal::Str("hello".into()));
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Str("hello".into()));
}
#[test]
fn test_eval_addition() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::BinOp {
op: BinOp::Add,
left: Box::new(Expr::Literal(Literal::Int(3))),
right: Box::new(Expr::Literal(Literal::Int(4))),
};
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Int(7));
}
#[test]
fn test_eval_multiplication() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::BinOp {
op: BinOp::Mul,
left: Box::new(Expr::Literal(Literal::Int(6))),
right: Box::new(Expr::Literal(Literal::Int(7))),
};
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Int(42));
}
#[test]
fn test_eval_equality_true() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::BinOp {
op: BinOp::Eq,
left: Box::new(Expr::Literal(Literal::Int(42))),
right: Box::new(Expr::Literal(Literal::Int(42))),
};
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Bool(true));
}
#[test]
fn test_eval_greater_than() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::BinOp {
op: BinOp::Gt,
left: Box::new(Expr::Literal(Literal::Int(5))),
right: Box::new(Expr::Literal(Literal::Int(3))),
};
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Bool(true));
}
#[test]
fn test_eval_activate_empty_graph() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Activate {
type_name: "Customer".into(),
query: "anything".into(),
};
let result = e.eval_expr(&expr).unwrap();
assert!(matches!(result, EvalValue::NodeList(ref v) if v.is_empty()));
}
#[test]
fn test_eval_activate_with_seeds() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson, founding member", 0.9, None);
let mut e = fresh_eval(&g);
let expr = Expr::Activate {
type_name: "Customer".into(),
query: "founding".into(),
};
let result = e.eval_expr(&expr).unwrap();
assert!(matches!(result, EvalValue::NodeList(ref v) if v.len() == 1));
}
#[test]
fn test_eval_let_and_ident() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
e.locals.insert("x".into(), EvalValue::Int(10));
let expr = Expr::Ident("x".into());
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Int(10));
}
#[test]
fn test_eval_reason_empty_graph() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Call {
func: Box::new(Expr::Ident("reason".into())),
args: vec![Expr::Literal(Literal::Str("Is there a customer?".into()))],
};
let result = e.eval_expr(&expr).unwrap();
match result {
EvalValue::Reasoning(r) => assert_eq!(r.verdict, "Insufficient"),
_ => panic!("expected Reasoning"),
}
}
}
+335
View File
@@ -0,0 +1,335 @@
//! In-memory graph for test seeding and activation.
//!
//! `TestGraph` provides the runtime behind `seed Node { ... }`, `seed Edge { ... }`,
//! `activate T where "query"`, and `reason("hypothesis")` in test blocks.
//!
//! For **unit tests** the graph is in-memory only — no disk, no external DB.
//! For **e2e tests** the graph would delegate to a real Engram database
//! (full implementation when Engram DB Rust client is available).
use std::collections::HashMap;
/// Unique node identifier (simplified UUID representation).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct NodeId(pub String);
impl NodeId {
pub fn new() -> Self {
// Simple deterministic ID for tests (real impl would use uuid crate)
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(1);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
NodeId(format!("node-{n:08x}"))
}
}
impl Default for NodeId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// A node in the test graph.
#[derive(Debug, Clone)]
pub struct GraphNode {
pub id: NodeId,
pub node_type: String,
pub content: String,
pub importance: f32,
#[allow(dead_code)]
pub tier: Option<String>,
}
/// A directed edge between two graph nodes.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct GraphEdge {
pub from: NodeId,
pub to: NodeId,
pub relation: String,
pub weight: f32,
}
/// A node returned by an `activate` query.
#[derive(Debug, Clone, PartialEq)]
pub struct ActivatedNode {
pub id: NodeId,
pub node_type: String,
pub content: String,
pub importance: f32,
}
/// The verdict of a `reason()` call.
#[derive(Debug, Clone, PartialEq)]
pub enum ReasoningVerdict {
/// Evidence found; hypothesis is supported.
Supported,
/// Evidence found; hypothesis is contradicted.
Contradicted,
/// Insufficient evidence to evaluate hypothesis.
Insufficient,
}
impl std::fmt::Display for ReasoningVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReasoningVerdict::Supported => write!(f, "Supported"),
ReasoningVerdict::Contradicted => write!(f, "Contradicted"),
ReasoningVerdict::Insufficient => write!(f, "Insufficient"),
}
}
}
/// Result of a `reason()` call.
#[derive(Debug, Clone)]
pub struct ReasoningResult {
pub verdict: ReasoningVerdict,
pub evidence: Vec<ActivatedNode>,
pub confidence: f32,
}
/// The in-memory graph used during unit tests.
///
/// Seeds accumulate nodes and edges. Activation queries search over them
/// using simple substring/keyword matching (a proxy for real embedding search).
pub struct TestGraph {
nodes: HashMap<NodeId, GraphNode>,
edges: Vec<GraphEdge>,
}
impl TestGraph {
/// Create an empty graph (unit test mode — pure in-memory).
pub fn new() -> Self {
Self {
nodes: HashMap::new(),
edges: Vec::new(),
}
}
/// Seed a node into the graph. Returns the node's ID.
pub fn seed_node(
&mut self,
node_type: &str,
content: &str,
importance: f32,
tier: Option<&str>,
) -> NodeId {
let id = NodeId::new();
let node = GraphNode {
id: id.clone(),
node_type: node_type.to_string(),
content: content.to_string(),
importance,
tier: tier.map(String::from),
};
self.nodes.insert(id.clone(), node);
id
}
/// Seed a directed edge between two nodes.
pub fn seed_edge(&mut self, from: NodeId, to: NodeId, relation: &str, weight: f32) {
self.edges.push(GraphEdge {
from,
to,
relation: relation.to_string(),
weight,
});
}
/// Activate — query nodes by type and keyword relevance.
///
/// Matches nodes whose `node_type` equals `node_type` (case-insensitive)
/// and whose content contains any keyword from the query.
/// Results are sorted by importance descending.
pub fn activate(&self, query: &str, node_type: Option<&str>) -> Vec<ActivatedNode> {
let query_lower = query.to_lowercase();
let keywords: Vec<&str> = query_lower.split_whitespace().collect();
let mut results: Vec<ActivatedNode> = self
.nodes
.values()
.filter(|node| {
// Type filter
if let Some(ty) = node_type {
if !node.node_type.eq_ignore_ascii_case(ty) {
return false;
}
}
// If no query keywords, match all nodes of that type
if keywords.is_empty() {
return true;
}
// Keyword relevance: content must contain at least one keyword
let content_lower = node.content.to_lowercase();
keywords.iter().any(|kw| content_lower.contains(kw))
})
.map(|node| ActivatedNode {
id: node.id.clone(),
node_type: node.node_type.clone(),
content: node.content.clone(),
importance: node.importance,
})
.collect();
results.sort_by(|a, b| b.importance.partial_cmp(&a.importance).unwrap_or(std::cmp::Ordering::Equal));
results
}
/// Reason — evaluate a hypothesis against the seeded graph.
///
/// If the graph is empty, returns `Insufficient`.
/// If matching nodes exist, returns `Supported` with those nodes.
pub fn reason(&self, hypothesis: &str) -> ReasoningResult {
if self.nodes.is_empty() {
return ReasoningResult {
verdict: ReasoningVerdict::Insufficient,
evidence: vec![],
confidence: 0.0,
};
}
let evidence = self.activate(hypothesis, None);
if evidence.is_empty() {
ReasoningResult {
verdict: ReasoningVerdict::Insufficient,
evidence: vec![],
confidence: 0.0,
}
} else {
let confidence = evidence.iter().map(|n| n.importance).sum::<f32>()
/ evidence.len() as f32;
ReasoningResult {
verdict: ReasoningVerdict::Supported,
evidence,
confidence,
}
}
}
/// Number of seeded nodes.
pub fn node_count(&self) -> usize {
self.nodes.len()
}
/// Number of seeded edges.
pub fn edge_count(&self) -> usize {
self.edges.len()
}
}
impl Default for TestGraph {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_seed_node_returns_id() {
let mut g = TestGraph::new();
let id = g.seed_node("Customer", "Will Anderson", 0.9, Some("Semantic"));
assert!(!id.0.is_empty());
}
#[test]
fn test_activate_returns_matching_nodes() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson, founding member", 0.9, None);
g.seed_node("Customer", "Jane Smith, standard client", 0.6, None);
let results = g.activate("founding", Some("Customer"));
assert_eq!(results.len(), 1);
assert!(results[0].content.contains("Will Anderson"));
}
#[test]
fn test_activate_empty_query_matches_all_of_type() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Alice", 0.9, None);
g.seed_node("Customer", "Bob", 0.8, None);
g.seed_node("Order", "Order #1", 0.7, None);
let results = g.activate("", Some("Customer"));
assert_eq!(results.len(), 2);
}
#[test]
fn test_activate_wrong_type_returns_empty() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson", 0.9, None);
let results = g.activate("Will", Some("Order"));
assert!(results.is_empty());
}
#[test]
fn test_activate_sorts_by_importance() {
let mut g = TestGraph::new();
g.seed_node("Item", "low importance apple", 0.3, None);
g.seed_node("Item", "high importance apple", 0.9, None);
g.seed_node("Item", "medium importance apple", 0.6, None);
let results = g.activate("apple", Some("Item"));
assert_eq!(results.len(), 3);
assert!(results[0].importance >= results[1].importance);
assert!(results[1].importance >= results[2].importance);
}
#[test]
fn test_activate_no_type_filter() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson", 0.9, None);
g.seed_node("Order", "Will's order", 0.7, None);
let results = g.activate("Will", None);
assert_eq!(results.len(), 2);
}
#[test]
fn test_reason_empty_graph_returns_insufficient() {
let g = TestGraph::new();
let result = g.reason("Is there a customer?");
assert_eq!(result.verdict, ReasoningVerdict::Insufficient);
assert_eq!(result.confidence, 0.0);
}
#[test]
fn test_reason_with_matching_nodes_returns_supported() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson, founding member", 0.9, None);
let result = g.reason("founding member");
assert_eq!(result.verdict, ReasoningVerdict::Supported);
assert!(!result.evidence.is_empty());
}
#[test]
fn test_reason_no_match_returns_insufficient() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson", 0.9, None);
let result = g.reason("quantum teleportation");
assert_eq!(result.verdict, ReasoningVerdict::Insufficient);
}
#[test]
fn test_seed_edge() {
let mut g = TestGraph::new();
let cust = g.seed_node("Customer", "Will", 0.9, None);
let order = g.seed_node("Order", "Order #1", 0.7, None);
g.seed_edge(cust, order, "Purchased", 0.9);
assert_eq!(g.edge_count(), 1);
}
#[test]
fn test_node_count() {
let mut g = TestGraph::new();
assert_eq!(g.node_count(), 0);
g.seed_node("X", "content", 0.5, None);
assert_eq!(g.node_count(), 1);
g.seed_node("Y", "more content", 0.5, None);
assert_eq!(g.node_count(), 2);
}
}
+33
View File
@@ -0,0 +1,33 @@
//! el-test — Engram language unified testing framework.
//!
//! # Core insight
//!
//! All Engram state is graph nodes. A test seeds the graph and makes
//! assertions. Unit test = seed a few nodes in-memory. E2e test = point at
//! the real Engram database. **The test code is identical. Only the graph
//! differs.** No mocking framework. No dependency injection. One syntax.
//!
//! # Usage
//!
//! ```rust,ignore
//! use el_test::{TestRunner, TestReport};
//!
//! let source = std::fs::read_to_string("tests.el").unwrap();
//! let tests = el_test::discover(&source).unwrap();
//! let runner = TestRunner::new();
//! let report = runner.run_all(&tests, None);
//! report.print();
//! ```
mod discovery;
mod eval;
mod graph;
mod report;
mod runner;
mod types;
pub use discovery::discover;
pub use graph::TestGraph;
pub use report::TestReport;
pub use runner::TestRunner;
pub use types::{AssertionResult, TestCase, TestResult, TestStatus, TestTarget};
+319
View File
@@ -0,0 +1,319 @@
//! Test report formatting — human-readable, JSON, and JUnit XML output.
use crate::types::{TestResult, TestStatus};
/// Aggregated report for a set of test runs.
pub struct TestReport {
pub total: u32,
pub passed: u32,
pub failed: u32,
pub skipped: u32,
pub errors: u32,
pub duration_ms: u64,
pub results: Vec<TestResult>,
}
impl TestReport {
/// Build a report from a slice of individual test results.
pub fn from_results(results: Vec<TestResult>) -> Self {
let total = results.len() as u32;
let passed = results.iter().filter(|r| r.status == TestStatus::Pass).count() as u32;
let failed = results.iter().filter(|r| r.status == TestStatus::Fail).count() as u32;
let skipped = results.iter().filter(|r| r.status == TestStatus::Skip).count() as u32;
let errors = results.iter().filter(|r| r.status == TestStatus::Error).count() as u32;
let duration_ms = results.iter().map(|r| r.duration_ms).sum();
Self { total, passed, failed, skipped, errors, duration_ms, results }
}
/// Print a human-readable summary to stdout.
pub fn print(&self) {
let target_label = format!("({}ms total)", self.duration_ms);
println!("\nRunning {} tests...\n", self.total);
for r in &self.results {
let icon = match r.status {
TestStatus::Pass => " ok ",
TestStatus::Fail => " FAIL ",
TestStatus::Skip => " SKIP ",
TestStatus::Error => "ERROR ",
};
println!(" [{icon}] {} ({}ms)", r.name, r.duration_ms);
// Show failing assertions
if r.status == TestStatus::Fail {
for (i, a) in r.assertions.iter().enumerate() {
if !a.passed {
println!(" assert {}", a.expression);
if let Some(actual) = &a.actual {
println!(" actual: {actual}");
}
if let Some(expected) = &a.expected {
println!(" expected: {expected}");
}
println!(" at assertion {}", i + 1);
}
}
}
if let Some(err) = &r.error {
println!(" error: {err}");
}
}
println!("\nResults: {} passed, {} failed, {} skipped {}", self.passed, self.failed, self.skipped, target_label);
if self.errors > 0 {
println!(" ({} error(s) — see above)", self.errors);
}
}
/// Serialize to JSON.
pub fn to_json(&self) -> String {
let results: Vec<serde_json::Value> = self
.results
.iter()
.map(|r| {
let assertions: Vec<serde_json::Value> = r
.assertions
.iter()
.map(|a| {
serde_json::json!({
"expression": a.expression,
"passed": a.passed,
"actual": a.actual,
"expected": a.expected,
})
})
.collect();
serde_json::json!({
"name": r.name,
"target": r.target.to_string(),
"status": r.status.to_string(),
"duration_ms": r.duration_ms,
"assertions": assertions,
"error": r.error,
})
})
.collect();
let report = serde_json::json!({
"total": self.total,
"passed": self.passed,
"failed": self.failed,
"skipped": self.skipped,
"errors": self.errors,
"duration_ms": self.duration_ms,
"results": results,
});
serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
}
/// Serialize to JUnit XML (for CI integration).
pub fn to_junit_xml(&self) -> String {
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str(&format!(
"<testsuite name=\"el\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\" time=\"{}\">\n",
self.total,
self.failed,
self.errors,
self.skipped,
self.duration_ms as f64 / 1000.0,
));
for r in &self.results {
let classname = "el_test";
let time = r.duration_ms as f64 / 1000.0;
let name_escaped = xml_escape(&r.name);
match r.status {
TestStatus::Pass => {
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\"/>\n"
));
}
TestStatus::Fail => {
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n"
));
for a in r.assertions.iter().filter(|a| !a.passed) {
let msg = xml_escape(&a.expression);
let details = match (&a.actual, &a.expected) {
(Some(act), Some(exp)) => format!("actual: {act}, expected: {exp}"),
(Some(act), None) => format!("actual: {act}"),
_ => "assertion failed".to_string(),
};
let details_esc = xml_escape(&details);
xml.push_str(&format!(
" <failure message=\"{msg}\">{details_esc}</failure>\n"
));
}
xml.push_str(" </testcase>\n");
}
TestStatus::Skip => {
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n <skipped/>\n </testcase>\n"
));
}
TestStatus::Error => {
let err_msg = xml_escape(r.error.as_deref().unwrap_or("unknown error"));
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n <error message=\"{err_msg}\"/>\n </testcase>\n"
));
}
}
}
xml.push_str("</testsuite>\n");
xml
}
/// Whether the overall test run passed (no failures or errors).
pub fn is_pass(&self) -> bool {
self.failed == 0 && self.errors == 0
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AssertionResult, TestTarget};
fn make_pass(name: &str) -> TestResult {
TestResult {
name: name.to_string(),
target: TestTarget::Unit,
status: TestStatus::Pass,
duration_ms: 5,
assertions: vec![AssertionResult {
expression: "x == 42".into(),
passed: true,
actual: Some("true".into()),
expected: None,
}],
error: None,
}
}
fn make_fail(name: &str) -> TestResult {
TestResult {
name: name.to_string(),
target: TestTarget::Unit,
status: TestStatus::Fail,
duration_ms: 3,
assertions: vec![AssertionResult {
expression: "x == 99".into(),
passed: false,
actual: Some("42".into()),
expected: Some("99".into()),
}],
error: None,
}
}
fn make_skip(name: &str) -> TestResult {
TestResult {
name: name.to_string(),
target: TestTarget::E2e,
status: TestStatus::Skip,
duration_ms: 0,
assertions: vec![],
error: Some("ENGRAM_URL not set".into()),
}
}
#[test]
fn test_report_counts() {
let report = TestReport::from_results(vec![
make_pass("a"),
make_fail("b"),
make_skip("c"),
]);
assert_eq!(report.total, 3);
assert_eq!(report.passed, 1);
assert_eq!(report.failed, 1);
assert_eq!(report.skipped, 1);
}
#[test]
fn test_report_is_pass() {
let report = TestReport::from_results(vec![make_pass("a"), make_pass("b")]);
assert!(report.is_pass());
}
#[test]
fn test_report_is_not_pass_on_fail() {
let report = TestReport::from_results(vec![make_pass("a"), make_fail("b")]);
assert!(!report.is_pass());
}
#[test]
fn test_to_json_valid() {
let report = TestReport::from_results(vec![make_pass("test")]);
let json = report.to_json();
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["total"], 1);
assert_eq!(parsed["passed"], 1);
}
#[test]
fn test_to_json_contains_results() {
let report = TestReport::from_results(vec![make_pass("hello")]);
let json = report.to_json();
assert!(json.contains("hello"));
}
#[test]
fn test_to_junit_xml_valid() {
let report = TestReport::from_results(vec![make_pass("a"), make_fail("b")]);
let xml = report.to_junit_xml();
assert!(xml.starts_with("<?xml"));
assert!(xml.contains("<testsuite"));
assert!(xml.contains("</testsuite>"));
}
#[test]
fn test_to_junit_xml_pass_testcase() {
let report = TestReport::from_results(vec![make_pass("arithmetic")]);
let xml = report.to_junit_xml();
assert!(xml.contains("arithmetic"));
assert!(!xml.contains("<failure"));
}
#[test]
fn test_to_junit_xml_fail_testcase() {
let report = TestReport::from_results(vec![make_fail("failing")]);
let xml = report.to_junit_xml();
assert!(xml.contains("<failure"));
}
#[test]
fn test_to_junit_xml_skipped() {
let report = TestReport::from_results(vec![make_skip("e2e")]);
let xml = report.to_junit_xml();
assert!(xml.contains("<skipped"));
}
#[test]
fn test_duration_sum() {
let report = TestReport::from_results(vec![make_pass("a"), make_pass("b")]);
assert_eq!(report.duration_ms, 10); // 5 + 5
}
#[test]
fn test_xml_escape() {
let escaped = super::xml_escape("a < b & c > d \"e\" 'f'");
assert!(escaped.contains("&lt;"));
assert!(escaped.contains("&amp;"));
assert!(escaped.contains("&gt;"));
assert!(escaped.contains("&quot;"));
}
}
+382
View File
@@ -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);
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Core data types for the testing framework.
use el_parser::Stmt;
/// Which graph a test should execute against.
#[derive(Debug, Clone, PartialEq)]
pub enum TestTarget {
/// In-memory graph — default, zero external dependencies.
Unit,
/// Real Engram database pointed at by `ENGRAM_URL` / `ENGRAM_DB_PATH`.
E2e,
/// Run against both unit (in-memory) and e2e (real DB).
Both,
}
impl std::fmt::Display for TestTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TestTarget::Unit => write!(f, "unit"),
TestTarget::E2e => write!(f, "e2e"),
TestTarget::Both => write!(f, "both"),
}
}
}
impl From<el_parser::TestTarget> for TestTarget {
fn from(t: el_parser::TestTarget) -> Self {
match t {
el_parser::TestTarget::Unit => TestTarget::Unit,
el_parser::TestTarget::E2e => TestTarget::E2e,
el_parser::TestTarget::Both => TestTarget::Both,
}
}
}
/// A single test case extracted from an `.el` source file.
pub struct TestCase {
pub name: String,
pub target: TestTarget,
/// The body statements of the test block.
pub body: Vec<Stmt>,
}
/// Status of a single test run.
#[derive(Debug, Clone, PartialEq)]
pub enum TestStatus {
Pass,
Fail,
/// Skipped because the required target is not available.
Skip,
/// Unexpected runtime error (not an assertion failure).
Error,
}
impl std::fmt::Display for TestStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TestStatus::Pass => write!(f, "pass"),
TestStatus::Fail => write!(f, "fail"),
TestStatus::Skip => write!(f, "skip"),
TestStatus::Error => write!(f, "error"),
}
}
}
/// Result of evaluating a single `assert` statement.
#[derive(Debug, Clone)]
pub struct AssertionResult {
/// The `assert` expression as source text.
pub expression: String,
pub passed: bool,
pub actual: Option<String>,
pub expected: Option<String>,
}
/// The full result of one test execution.
pub struct TestResult {
pub name: String,
pub target: TestTarget,
pub status: TestStatus,
pub duration_ms: u64,
pub assertions: Vec<AssertionResult>,
pub error: Option<String>,
}