Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions src/graph/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,4 +347,129 @@ topic main:
// At least one edge should exist (the Routes edge from start_agent → main)
assert!(graph.edge_count() > 0, "Expected at least one edge in the graph");
}

/// Source with a topic that defines an action def and a reasoning action that
/// invokes it via `@actions.<name>`. Used to test Invokes-edge queries.
fn invocation_source() -> &'static str {
r#"config:
agent_name: "Test"

start_agent selector:
description: "Route to main"
reasoning:
instructions: "Select"
actions:
go_main: @utils.transition to @topic.main
description: "Enter main"

topic main:
description: "Main topic"

actions:
lookup:
description: "Look up a record"
inputs:
id: string
description: "Record ID"
outputs:
name: string
description: "Record name"
target: "flow://Lookup"

reasoning:
instructions: "Help"
actions:
do_lookup: @actions.lookup
description: "Perform the lookup"
"#
}

/// Source with two topics that form a cycle: topic_a → topic_b → topic_a.
fn cyclic_two_topic_source() -> &'static str {
r#"config:
agent_name: "Test"

start_agent selector:
description: "Route"
reasoning:
instructions: "Select"
actions:
go_a: @utils.transition to @topic.topic_a
description: "Go to A"

topic topic_a:
description: "Topic A"
reasoning:
instructions: "In A"
actions:
go_b: @utils.transition to @topic.topic_b
description: "Go to B"

topic topic_b:
description: "Topic B"
reasoning:
instructions: "In B"
actions:
go_a: @utils.transition to @topic.topic_a
description: "Back to A"
"#
}

#[test]
fn test_find_usages_returns_referencing_nodes() {
// topic_b is referenced by topic_a's reasoning action via
// `@utils.transition to @topic.topic_b`. find_usages(topic_b) should
// return at least that reasoning action node as an incoming edge source.
let graph = parse_and_build(two_topic_source());
let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
let usages = graph.find_usages(topic_b_idx);
assert!(
!usages.is_empty(),
"Expected at least one node referencing topic_b via an incoming edge"
);
}

#[test]
fn test_find_action_invokers_returns_reasoning_action() {
// The reasoning action `do_lookup` references `@actions.lookup`, creating
// a RefEdge::Invokes edge from the reasoning action node to the action-def
// node. find_action_invokers(lookup_def) should return exactly that one
// reasoning action.
let graph = parse_and_build(invocation_source());
let lookup_idx = graph
.get_action_def("main", "lookup")
.expect("lookup action def not found");
let invokers = graph.find_action_invokers(lookup_idx);
assert_eq!(
invokers.len(),
1,
"Expected exactly 1 invoker for the lookup action def"
);
}

#[test]
fn test_get_topic_reasoning_actions_count() {
// topic 'main' defines exactly one reasoning action (`do_lookup`).
// get_topic_reasoning_actions("main") should return a vec of length 1.
let graph = parse_and_build(invocation_source());
let actions = graph.get_topic_reasoning_actions("main");
assert_eq!(
actions.len(),
1,
"Expected 1 reasoning action in topic 'main'"
);
}

#[test]
fn test_topic_execution_order_returns_none_for_cyclic_graph() {
// topic_a → topic_b → topic_a forms a cycle; topological sort is
// impossible for a directed cyclic graph. topic_execution_order() must
// return None when the graph contains a cycle.
let graph = parse_and_build(cyclic_two_topic_source());
let order = graph.topic_execution_order();
assert!(
order.is_none(),
"Expected None for a cyclic graph, but topic_execution_order returned Some"
);
}
}
43 changes: 42 additions & 1 deletion tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ recipe_test!(

#[cfg(test)]
mod parser_unit_tests {
use busbar_sf_agentscript::{lexer, parse_source};
use busbar_sf_agentscript::{lexer, parse_source, parse_with_errors};
use chumsky::prelude::*;

#[test]
Expand Down Expand Up @@ -271,4 +271,45 @@ topic main:
.collect();
assert!(tokens.iter().any(|t| matches!(t, lexer::Token::Config)));
}

#[test]
fn test_parse_error_numeric_literal_for_agent_name() {
// The `agent_name` field expects a string literal (`"..."`). A numeric
// literal (123) is a different token type that `spanned_string()` rejects,
// so the parser should fail and return `Err`.
let source = "config:\n agent_name: 123\n";
let result = parse_source(source);
assert!(
result.is_err(),
"Expected parse error for numeric agent_name, but parse succeeded"
);
}

#[test]
fn test_parse_error_topic_missing_name() {
// A `topic` keyword must be followed by an identifier (its name) and then
// a colon. A bare `topic:` with the colon in the name position should
// trigger a parse error because `spanned_ident()` only matches `Ident`
// tokens, not `Colon`.
let source = "config:\n agent_name: \"Test\"\n\ntopic:\n description: \"No name\"\n";
let result = parse_source(source);
assert!(
result.is_err(),
"Expected parse error for topic without a name, but parse succeeded"
);
}

#[test]
fn test_parse_with_errors_collects_errors_for_malformed_input() {
// `parse_with_errors` returns both a partial AST and an error list.
// For clearly invalid input the error list must be non-empty, confirming
// that error collection works independently of whether recovery produces
// a partial tree.
let source = "config:\n agent_name: 999\n";
let (_partial, errors) = parse_with_errors(source);
assert!(
!errors.is_empty(),
"Expected at least one error message for malformed config input"
);
}
}
35 changes: 35 additions & 0 deletions tests/test_serializer_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,38 @@ topic main:
assert!(topic.before_reasoning.is_some(), "before_reasoning lost after roundtrip");
assert!(topic.after_reasoning.is_some(), "after_reasoning lost after roundtrip");
}

#[test]
fn test_roundtrip_system_block() {
// Covers the `system:` block — global instructions and welcome/error message
// templates. The serializer writes this block but no roundtrip test existed.
// Ensures all system block content survives a parse → serialize → parse cycle.
let original = r#"config:
agent_name: "SupportAgent"

system:
instructions: "You are a helpful support agent."
messages:
welcome: "Hello! How can I help you today?"
error: "I encountered an issue. Please try again."

topic main:
description: "Main"
"#;

let ast = parse(original).expect("Failed to parse original");
let serialized = serialize(&ast);

assert!(serialized.contains("system:"), "Missing system block in serialized output");
assert!(serialized.contains("instructions:"), "Missing instructions in serialized output");
assert!(serialized.contains("welcome:"), "Missing welcome message in serialized output");

let reparsed = parse(&serialized).expect("Failed to reparse serialized output");
assert!(reparsed.system.is_some(), "system block lost after roundtrip");
let sys = &reparsed.system.as_ref().unwrap().node;
assert!(sys.instructions.is_some(), "instructions lost after roundtrip");
assert!(sys.messages.is_some(), "messages block lost after roundtrip");
let messages = sys.messages.as_ref().unwrap();
assert!(messages.node.welcome.is_some(), "welcome message lost after roundtrip");
assert!(messages.node.error.is_some(), "error message lost after roundtrip");
}