#![allow(clippy::unwrap_used, clippy::indexing_slicing)]
use entelix_core::{Error, ExecutionContext, Result};
use entelix_graph::{DEFAULT_RECURSION_LIMIT, StateGraph};
use entelix_runnable::{Runnable, RunnableLambda};
#[derive(Clone, Debug, PartialEq, Eq)]
struct Counter {
n: i32,
trail: Vec<&'static str>,
}
fn add_one(label: &'static str) -> RunnableLambda<Counter, Counter> {
RunnableLambda::new(move |mut s: Counter, _ctx| async move {
s.n += 1;
s.trail.push(label);
Ok::<_, _>(s)
})
}
#[tokio::test]
async fn linear_three_node_graph_executes_in_order() -> Result<()> {
let graph = StateGraph::<Counter>::new()
.add_node("a", add_one("a"))
.add_node("b", add_one("b"))
.add_node("c", add_one("c"))
.add_edge("a", "b")
.add_edge("b", "c")
.set_entry_point("a")
.add_finish_point("c")
.compile()?;
let out = graph
.invoke(
Counter {
n: 0,
trail: vec![],
},
&ExecutionContext::new(),
)
.await?;
assert_eq!(out.n, 3);
assert_eq!(out.trail, vec!["a", "b", "c"]);
Ok(())
}
#[tokio::test]
async fn entry_point_finish_node_runs_once() -> Result<()> {
let graph = StateGraph::<Counter>::new()
.add_node("only", add_one("only"))
.set_entry_point("only")
.add_finish_point("only")
.compile()?;
let out = graph
.invoke(
Counter {
n: 0,
trail: vec![],
},
&ExecutionContext::new(),
)
.await?;
assert_eq!(out.n, 1);
assert_eq!(out.trail, vec!["only"]);
Ok(())
}
#[tokio::test]
async fn recursion_limit_breaks_infinite_cycle() {
let graph = StateGraph::<Counter>::new()
.add_node("loop", add_one("loop"))
.add_node("sink", add_one("sink"))
.add_edge("loop", "loop") .add_finish_point("sink") .set_entry_point("loop")
.with_recursion_limit(7)
.compile()
.unwrap();
let err = graph
.invoke(
Counter {
n: 0,
trail: vec![],
},
&ExecutionContext::new(),
)
.await
.unwrap_err();
assert!(
matches!(&err, Error::InvalidRequest(msg) if msg.contains("recursion limit")),
"got {err:?}"
);
}
#[tokio::test]
async fn run_overrides_max_iterations_lowers_effective_cap() {
let graph = StateGraph::<Counter>::new()
.add_node("loop", add_one("loop"))
.add_node("sink", add_one("sink"))
.add_edge("loop", "loop")
.add_finish_point("sink")
.set_entry_point("loop")
.with_recursion_limit(50)
.compile()
.unwrap();
let ctx = ExecutionContext::new()
.add_extension(entelix_core::RunOverrides::new().with_max_iterations(5));
let err = graph
.invoke(
Counter {
n: 0,
trail: vec![],
},
&ctx,
)
.await
.unwrap_err();
assert!(
matches!(&err, Error::InvalidRequest(msg) if msg.contains("recursion limit (5)")),
"expected effective cap 5 in diagnostic, got {err:?}"
);
}
#[tokio::test]
async fn run_overrides_max_iterations_cannot_raise_compile_time_cap() {
let graph = StateGraph::<Counter>::new()
.add_node("loop", add_one("loop"))
.add_node("sink", add_one("sink"))
.add_edge("loop", "loop")
.add_finish_point("sink")
.set_entry_point("loop")
.with_recursion_limit(5)
.compile()
.unwrap();
let ctx = ExecutionContext::new()
.add_extension(entelix_core::RunOverrides::new().with_max_iterations(100));
let err = graph
.invoke(
Counter {
n: 0,
trail: vec![],
},
&ctx,
)
.await
.unwrap_err();
assert!(
matches!(&err, Error::InvalidRequest(msg) if msg.contains("recursion limit (5)")),
"compile-time cap must remain authoritative; got {err:?}"
);
}
#[tokio::test]
async fn default_recursion_limit_is_25() {
let graph: StateGraph<Counter> = StateGraph::new();
assert_eq!(DEFAULT_RECURSION_LIMIT, 25);
let _ = graph; }
#[tokio::test]
async fn cancelled_token_short_circuits_invoke() {
let graph = StateGraph::<Counter>::new()
.add_node("a", add_one("a"))
.add_node("b", add_one("b"))
.add_edge("a", "b")
.set_entry_point("a")
.add_finish_point("b")
.compile()
.unwrap();
let ctx = ExecutionContext::new();
ctx.cancellation().cancel();
let err = graph
.invoke(
Counter {
n: 0,
trail: vec![],
},
&ctx,
)
.await
.unwrap_err();
assert!(matches!(err, Error::Cancelled));
}
#[test]
fn compile_without_entry_point_fails() {
let err = StateGraph::<Counter>::new()
.add_node("a", add_one("a"))
.add_finish_point("a")
.compile()
.unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn compile_with_unknown_entry_point_fails() {
let err = StateGraph::<Counter>::new()
.add_node("a", add_one("a"))
.set_entry_point("ghost")
.add_finish_point("a")
.compile()
.unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn compile_without_finish_points_fails() {
let err = StateGraph::<Counter>::new()
.add_node("a", add_one("a"))
.set_entry_point("a")
.compile()
.unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn compile_with_dangling_edge_fails() {
let err = StateGraph::<Counter>::new()
.add_node("a", add_one("a"))
.add_edge("a", "ghost")
.set_entry_point("a")
.add_finish_point("a")
.compile()
.unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn compile_with_orphan_node_fails() {
let err = StateGraph::<Counter>::new()
.add_node("a", add_one("a"))
.add_node("b", add_one("b"))
.add_edge("a", "b")
.set_entry_point("a")
.add_finish_point("a")
.compile()
.unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[tokio::test]
async fn compiled_graph_pipes_into_runnable_chain() -> Result<()> {
use entelix_runnable::RunnableExt;
let graph = StateGraph::<i32>::new()
.add_node(
"double",
RunnableLambda::new(|x: i32, _ctx| async move { Ok::<_, _>(x * 2) }),
)
.set_entry_point("double")
.add_finish_point("double")
.compile()?;
let stringify = RunnableLambda::new(|x: i32, _ctx| async move { Ok::<_, _>(format!("{x}")) });
let chain = graph.pipe(stringify);
let out = chain.invoke(21, &ExecutionContext::new()).await?;
assert_eq!(out, "42");
Ok(())
}
#[tokio::test]
async fn compiled_graph_can_be_a_node_inside_another_graph() -> Result<()> {
let inner = StateGraph::<i32>::new()
.add_node(
"double",
RunnableLambda::new(|x: i32, _ctx| async move { Ok::<_, _>(x * 2) }),
)
.set_entry_point("double")
.add_finish_point("double")
.compile()?;
let outer = StateGraph::<i32>::new()
.add_node("inner", inner)
.add_node(
"plus_one",
RunnableLambda::new(|x: i32, _ctx| async move { Ok::<_, _>(x + 1) }),
)
.add_edge("inner", "plus_one")
.set_entry_point("inner")
.add_finish_point("plus_one")
.compile()?;
let out = outer.invoke(10, &ExecutionContext::new()).await?;
assert_eq!(out, 21); Ok(())
}