mod common;
use common::*;
use std::sync::Arc;
use weavegraph::graphs::{EdgePredicate, GraphBuilder, GraphCompileError};
use weavegraph::node::NodePartial;
use weavegraph::reducers::Reducer;
use weavegraph::state::VersionedState;
use weavegraph::types::{ChannelType, NodeKind};
struct FirstExtraReducer;
impl Reducer for FirstExtraReducer {
fn apply(&self, _state: &mut VersionedState, _update: &NodePartial) {}
}
struct SecondExtraReducer;
impl Reducer for SecondExtraReducer {
fn apply(&self, _state: &mut VersionedState, _update: &NodePartial) {}
}
struct StableLabelReducerA;
impl Reducer for StableLabelReducerA {
fn definition_label(&self) -> &'static str {
"stable-extra-label"
}
fn apply(&self, _state: &mut VersionedState, _update: &NodePartial) {}
}
struct StableLabelReducerB;
impl Reducer for StableLabelReducerB {
fn definition_label(&self) -> &'static str {
"stable-extra-label"
}
fn apply(&self, _state: &mut VersionedState, _update: &NodePartial) {}
}
#[test]
fn conditional_edge_is_accessible_and_predicate_evaluates_correctly() {
let route_to_y: EdgePredicate = std::sync::Arc::new(|_s| vec!["Y".to_string()]);
let app = GraphBuilder::new()
.add_node(NodeKind::Custom("Y".into()), NoopNode)
.add_node(NodeKind::Custom("N".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("Y".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("N".into()))
.add_conditional_edge(NodeKind::Start, route_to_y.clone())
.add_edge(NodeKind::Custom("Y".into()), NodeKind::End)
.add_edge(NodeKind::Custom("N".into()), NodeKind::End)
.compile()
.unwrap();
assert_eq!(app.conditional_edges().len(), 1);
let ce = &app.conditional_edges()[0];
assert_eq!(ce.from(), &NodeKind::Start);
let snap = empty_snapshot();
assert_eq!((ce.predicate())(snap), vec!["Y".to_string()]);
}
#[test]
fn empty_graph_compilation_fails_with_missing_entry() {
assert!(matches!(
GraphBuilder::new().compile().err().unwrap(),
GraphCompileError::MissingEntry
));
}
#[test]
fn compiled_graph_contains_registered_nodes() {
let app = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::End)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.add_edge(NodeKind::Custom("B".into()), NodeKind::End)
.compile()
.unwrap();
assert_eq!(app.nodes().len(), 2);
assert!(app.nodes().contains_key(&NodeKind::Custom("A".into())));
assert!(app.nodes().contains_key(&NodeKind::Custom("B".into())));
}
#[test]
fn compiled_graph_contains_registered_edges() {
let app = GraphBuilder::new()
.add_node(NodeKind::Custom("C".to_string()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::End)
.add_edge(NodeKind::Start, NodeKind::Custom("C".to_string()))
.add_edge(NodeKind::Custom("C".to_string()), NodeKind::End)
.compile()
.unwrap();
assert_eq!(app.edges().len(), 2);
let edges = app.edges().get(&NodeKind::Start).unwrap();
assert_eq!(edges.len(), 2);
assert!(edges.contains(&NodeKind::End));
assert!(edges.contains(&NodeKind::Custom("C".to_string())));
}
#[test]
fn minimal_start_to_end_graph_compiles() {
let gb = GraphBuilder::new().add_edge(NodeKind::Start, NodeKind::End);
let app = gb.compile().unwrap();
assert_eq!(app.edges().len(), 1);
assert!(
app.edges()
.get(&NodeKind::Start)
.unwrap()
.contains(&NodeKind::End)
);
}
#[test]
fn graph_metadata_reflects_structure_and_hash_differs_for_distinct_definitions() {
let app_a = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.compile()
.unwrap();
let app_b = GraphBuilder::new()
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("B".into()), NodeKind::End)
.compile()
.unwrap();
let metadata = app_a.graph_metadata();
assert_eq!(metadata.graph_hash, app_a.graph_definition_hash());
assert_eq!(metadata.node_count, 1);
assert_eq!(metadata.edge_count, 2);
assert_eq!(metadata.conditional_edge_count, 0);
assert_ne!(app_a.graph_definition_hash(), app_b.graph_definition_hash());
}
#[test]
fn graph_hash_differs_when_reducer_type_changes() {
let app_a = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.with_reducer(ChannelType::Extra, Arc::new(FirstExtraReducer))
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.compile()
.unwrap();
let app_b = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.with_reducer(ChannelType::Extra, Arc::new(SecondExtraReducer))
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.compile()
.unwrap();
assert_ne!(app_a.graph_definition_hash(), app_b.graph_definition_hash());
}
#[test]
fn graph_hash_is_stable_regardless_of_builder_insertion_order() {
let app_a = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.add_edge(NodeKind::Custom("B".into()), NodeKind::End)
.compile()
.unwrap();
let app_b = GraphBuilder::new()
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_edge(NodeKind::Custom("B".into()), NodeKind::End)
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.add_edge(NodeKind::Start, NodeKind::Custom("B".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.compile()
.unwrap();
assert_eq!(app_a.graph_definition_hash(), app_b.graph_definition_hash());
}
#[test]
fn graph_hash_differs_when_conditional_edge_is_added() {
let route_to_end: EdgePredicate = Arc::new(|_snapshot| vec!["End".to_string()]);
let app_without_conditional = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.compile()
.unwrap();
let app_with_conditional = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.add_conditional_edge(NodeKind::Custom("A".into()), route_to_end)
.compile()
.unwrap();
assert_eq!(
app_with_conditional.graph_metadata().conditional_edge_count,
1
);
assert_ne!(
app_without_conditional.graph_definition_hash(),
app_with_conditional.graph_definition_hash()
);
}
#[test]
fn graph_hash_reflects_custom_reducer_definition_label() {
let app_a = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.with_reducer(ChannelType::Extra, Arc::new(StableLabelReducerA))
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.compile()
.unwrap();
let app_b = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.with_reducer(ChannelType::Extra, Arc::new(StableLabelReducerB))
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.compile()
.unwrap();
assert!(
app_a
.graph_metadata()
.reducer_signature
.iter()
.any(|entry| entry.contains("stable-extra-label"))
);
assert_eq!(app_a.graph_definition_hash(), app_b.graph_definition_hash());
}
#[test]
fn custom_nodekind_equality_compares_inner_string() {
let k1 = NodeKind::Custom("foo".to_string());
let k2 = NodeKind::Custom("foo".to_string());
let k3 = NodeKind::Custom("bar".to_string());
assert_eq!(k1, k2);
assert_ne!(k1, k3);
}
#[test]
fn graph_builder_accepts_runtime_config() {
use weavegraph::runtimes::RuntimeConfig;
let config = RuntimeConfig::new(Some("test_session".into()), None);
let builder = GraphBuilder::new()
.add_edge(NodeKind::Start, NodeKind::End)
.with_runtime_config(config);
let _app = builder.compile().unwrap();
}
#[test]
fn compile_rejects_graph_containing_a_cycle() {
let result = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("B".into()), NodeKind::Custom("A".into())) .compile();
assert!(result.is_err());
match result.err().unwrap() {
GraphCompileError::CycleDetected { cycle } => {
assert!(!cycle.is_empty());
assert!(cycle.contains(&NodeKind::Custom("A".into())));
assert!(cycle.contains(&NodeKind::Custom("B".into())));
}
e => panic!("Expected CycleDetected error, got: {:?}", e),
}
}
#[test]
fn compile_rejects_graph_containing_a_self_loop() {
let result = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::Custom("A".into())) .compile();
assert!(result.is_err());
match result.err().unwrap() {
GraphCompileError::CycleDetected { cycle } => {
assert!(!cycle.is_empty());
assert!(cycle.contains(&NodeKind::Custom("A".into())));
}
e => panic!("Expected CycleDetected error, got: {:?}", e),
}
}
#[test]
fn compile_accepts_acyclic_linear_graph() {
let result = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("B".into()), NodeKind::End)
.compile();
assert!(result.is_ok());
}
#[test]
fn compile_rejects_graph_with_unreachable_nodes() {
let result = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_node(NodeKind::Custom("X".into()), NoopNode) .add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("B".into()), NodeKind::End)
.compile();
assert!(result.is_err());
match result.err().unwrap() {
GraphCompileError::UnreachableNodes { nodes } => {
assert_eq!(nodes.len(), 1);
assert!(nodes.contains(&NodeKind::Custom("X".into())));
}
e => panic!("Expected UnreachableNodes error, got: {:?}", e),
}
}
#[test]
fn compile_accepts_fully_connected_graph() {
let result = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("B".into()), NodeKind::End)
.compile();
assert!(result.is_ok());
}
#[test]
fn compile_rejects_graph_with_nodes_having_no_path_to_end() {
let result = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::Custom("B".into()))
.compile();
assert!(result.is_err());
match result.err().unwrap() {
GraphCompileError::NoPathToEnd { nodes } => {
assert!(!nodes.is_empty());
assert!(nodes.contains(&NodeKind::Custom("A".into())));
assert!(nodes.contains(&NodeKind::Custom("B".into())));
}
e => panic!("Expected NoPathToEnd error, got: {:?}", e),
}
}
#[test]
fn compile_accepts_graph_where_all_nodes_reach_end() {
let result = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.compile();
assert!(result.is_ok());
}
#[test]
fn compile_rejects_duplicate_edges_with_error_detail() {
let result = GraphBuilder::new()
.add_edge(NodeKind::Start, NodeKind::End)
.add_edge(NodeKind::Start, NodeKind::End)
.compile();
assert!(result.is_err());
match result.err().unwrap() {
GraphCompileError::DuplicateEdge { from, to } => {
assert_eq!(from, NodeKind::Start);
assert_eq!(to, NodeKind::End);
}
e => panic!("Expected DuplicateEdge error, got: {:?}", e),
}
}
#[test]
fn compile_allows_multiple_edges_from_same_source_node() {
let result = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.add_edge(NodeKind::Custom("B".into()), NodeKind::End)
.compile();
assert!(result.is_ok());
}
#[test]
fn simple_process_node_graph_compiles() {
let result = GraphBuilder::new()
.add_node(NodeKind::Custom("process".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("process".into()))
.add_edge(NodeKind::Custom("process".into()), NodeKind::End)
.compile();
assert!(result.is_ok());
}
#[test]
fn builder_nodes_iter_yields_all_registered_nodes() {
let builder = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_node(NodeKind::Custom("C".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("B".into()), NodeKind::Custom("C".into()))
.add_edge(NodeKind::Custom("C".into()), NodeKind::End);
let nodes: Vec<_> = builder.nodes().collect();
assert_eq!(nodes.len(), 3);
assert!(nodes.contains(&&NodeKind::Custom("A".into())));
assert!(nodes.contains(&&NodeKind::Custom("B".into())));
assert!(nodes.contains(&&NodeKind::Custom("C".into())));
}
#[test]
fn builder_edges_iter_yields_all_registered_edges() {
let builder = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End);
let edges: Vec<_> = builder.edges().collect();
assert_eq!(edges.len(), 2);
assert!(edges.contains(&(&NodeKind::Start, &NodeKind::Custom("A".into()))));
assert!(edges.contains(&(&NodeKind::Custom("A".into()), &NodeKind::End)));
}
#[test]
fn builder_reports_correct_node_and_edge_counts() {
let builder = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.add_edge(NodeKind::Custom("B".into()), NodeKind::End);
assert_eq!(builder.node_count(), 2);
assert_eq!(builder.edge_count(), 4);
}
#[test]
fn topological_sort_respects_linear_dependency_order() {
let builder = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_node(NodeKind::Custom("C".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::Custom("B".into()))
.add_edge(NodeKind::Custom("B".into()), NodeKind::Custom("C".into()))
.add_edge(NodeKind::Custom("C".into()), NodeKind::End);
let sorted = builder.topological_sort();
assert_eq!(sorted[0], NodeKind::Start);
assert_eq!(sorted[sorted.len() - 1], NodeKind::End);
let a_pos = sorted
.iter()
.position(|n| n == &NodeKind::Custom("A".into()))
.unwrap();
let b_pos = sorted
.iter()
.position(|n| n == &NodeKind::Custom("B".into()))
.unwrap();
let c_pos = sorted
.iter()
.position(|n| n == &NodeKind::Custom("C".into()))
.unwrap();
assert!(a_pos < b_pos);
assert!(b_pos < c_pos);
}
#[test]
fn topological_sort_places_start_first_and_end_last() {
let builder = GraphBuilder::new()
.add_node(NodeKind::Custom("A".into()), NoopNode)
.add_node(NodeKind::Custom("B".into()), NoopNode)
.add_node(NodeKind::Custom("C".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("A".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("B".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("C".into()))
.add_edge(NodeKind::Custom("A".into()), NodeKind::End)
.add_edge(NodeKind::Custom("B".into()), NodeKind::End)
.add_edge(NodeKind::Custom("C".into()), NodeKind::End);
let sorted = builder.topological_sort();
assert_eq!(sorted[0], NodeKind::Start);
assert_eq!(sorted[sorted.len() - 1], NodeKind::End);
let start_pos = sorted.iter().position(|n| n == &NodeKind::Start).unwrap();
let end_pos = sorted.iter().position(|n| n == &NodeKind::End).unwrap();
let a_pos = sorted
.iter()
.position(|n| n == &NodeKind::Custom("A".into()))
.unwrap();
let b_pos = sorted
.iter()
.position(|n| n == &NodeKind::Custom("B".into()))
.unwrap();
let c_pos = sorted
.iter()
.position(|n| n == &NodeKind::Custom("C".into()))
.unwrap();
assert!(start_pos < a_pos && a_pos < end_pos);
assert!(start_pos < b_pos && b_pos < end_pos);
assert!(start_pos < c_pos && c_pos < end_pos);
assert!(a_pos < b_pos);
assert!(b_pos < c_pos);
}
#[test]
fn topological_sort_is_deterministic_and_lexicographic() {
let builder = GraphBuilder::new()
.add_node(NodeKind::Custom("Z".into()), NoopNode)
.add_node(NodeKind::Custom("Y".into()), NoopNode)
.add_node(NodeKind::Custom("X".into()), NoopNode)
.add_edge(NodeKind::Start, NodeKind::Custom("X".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("Y".into()))
.add_edge(NodeKind::Start, NodeKind::Custom("Z".into()))
.add_edge(NodeKind::Custom("X".into()), NodeKind::End)
.add_edge(NodeKind::Custom("Y".into()), NodeKind::End)
.add_edge(NodeKind::Custom("Z".into()), NodeKind::End);
let sorted1 = builder.topological_sort();
let sorted2 = builder.topological_sort();
assert_eq!(sorted1, sorted2);
let x_pos = sorted1
.iter()
.position(|n| n == &NodeKind::Custom("X".into()))
.unwrap();
let y_pos = sorted1
.iter()
.position(|n| n == &NodeKind::Custom("Y".into()))
.unwrap();
let z_pos = sorted1
.iter()
.position(|n| n == &NodeKind::Custom("Z".into()))
.unwrap();
assert!(x_pos < y_pos);
assert!(y_pos < z_pos);
}