diff --git a/src/graph/queries.rs b/src/graph/queries.rs index 7508e45..a095b79 100644 --- a/src/graph/queries.rs +++ b/src/graph/queries.rs @@ -302,6 +302,144 @@ topic topic_b: ); } + #[test] + fn test_find_variable_readers_via_with_clause() { + // A reasoning action that passes a variable via a `with` clause creates a + // RefEdge::Reads edge from the reasoning action to the variable. + // find_variable_readers() must return that reasoning action. + let source = r#"config: + agent_name: "Test" + +variables: + order_id: mutable string = "" + description: "Order identifier" + +topic main: + description: "Main" + + actions: + get_order: + description: "Retrieve order details" + inputs: + id: string + description: "Order ID" + outputs: + status: string + description: "Order status" + target: "flow://GetOrder" + + reasoning: + instructions: "Help" + actions: + fetch_order: @actions.get_order + description: "Fetch the order by ID" + with id = @variables.order_id +"#; + let graph = parse_and_build(source); + + let var_idx = graph.get_variable("order_id").expect("order_id variable not found"); + let action_idx = graph + .get_reasoning_action("main", "fetch_order") + .expect("fetch_order reasoning action not found"); + + let readers = graph.find_variable_readers(var_idx); + assert_eq!(readers.len(), 1, "Expected exactly 1 reader for order_id"); + assert_eq!( + readers.nodes[0], action_idx, + "Expected fetch_order reasoning action to be the reader" + ); + } + + #[test] + fn test_find_variable_writers_via_set_clause() { + // A reasoning action that writes to a variable via a `set` clause creates a + // RefEdge::Writes edge from the reasoning action to the variable. + // find_variable_writers() must return that reasoning action. + let source = r#"config: + agent_name: "Test" + +variables: + result_status: mutable string = "" + description: "Most-recent status value" + +topic main: + description: "Main" + + actions: + get_status: + description: "Retrieve status from backend" + outputs: + status: string + description: "Current status" + target: "flow://GetStatus" + + reasoning: + instructions: "Help" + actions: + check_status: @actions.get_status + description: "Check status and store result" + set @variables.result_status = @outputs.status +"#; + let graph = parse_and_build(source); + + let var_idx = graph.get_variable("result_status").expect("result_status variable not found"); + let action_idx = graph + .get_reasoning_action("main", "check_status") + .expect("check_status reasoning action not found"); + + let writers = graph.find_variable_writers(var_idx); + assert_eq!(writers.len(), 1, "Expected exactly 1 writer for result_status"); + assert_eq!( + writers.nodes[0], action_idx, + "Expected check_status reasoning action to be the writer" + ); + } + + #[test] + fn test_find_action_invokers_returns_reasoning_action() { + // A reasoning action whose target is `@actions.` creates a + // RefEdge::Invokes edge from the reasoning action to the action definition. + // find_action_invokers() must return that reasoning action. + let source = r#"config: + agent_name: "Test" + +topic main: + description: "Main" + + actions: + lookup_data: + description: "Look up data from backend" + inputs: + id: string + description: "Record identifier" + outputs: + result: string + description: "Lookup result" + target: "flow://LookupData" + + reasoning: + instructions: "Help" + actions: + do_lookup: @actions.lookup_data + description: "Perform the data lookup" +"#; + let graph = parse_and_build(source); + + let action_def_idx = graph + .get_action_def("main", "lookup_data") + .expect("lookup_data action def not found"); + let reasoning_idx = graph + .get_reasoning_action("main", "do_lookup") + .expect("do_lookup reasoning action not found"); + + let invokers = graph.find_action_invokers(action_def_idx); + assert_eq!(invokers.len(), 1, "Expected exactly 1 invoker for lookup_data"); + assert_eq!( + invokers.nodes[0], reasoning_idx, + "Expected do_lookup reasoning action to be the invoker" + ); + } + #[test] fn test_stats_counts_nodes_correctly() { // Verify that stats() correctly counts topics, action defs, and variables. diff --git a/tests/integration_test.rs b/tests/integration_test.rs index f5db09b..6cdbb6e 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -271,4 +271,36 @@ topic main: .collect(); assert!(tokens.iter().any(|t| matches!(t, lexer::Token::Config))); } + + #[test] + fn test_parse_none_default_on_non_boolean_variable_returns_error() { + // `= None` is only a valid default for `boolean` types. The parser's + // validate() callback emits a custom error when it appears on any other type + // (e.g. `integer`). This test verifies that parse_source returns Err and + // that at least one error message mentions None or the boolean type + // restriction — covering a previously untested parser error path. + let source = r#"config: + agent_name: "Test" + +variables: + counter: mutable integer = None + description: "Counter — None is not a valid default for integer" + +topic main: + description: "Main" +"#; + let result = parse_source(source); + assert!( + result.is_err(), + "Expected parse_source to return Err for '= None' on a non-boolean type, got Ok" + ); + let errors = result.unwrap_err(); + assert!(!errors.is_empty(), "Expected at least one error message"); + // The validate() callback formats a message that includes "None" and "boolean" + assert!( + errors.iter().any(|e| e.contains("None") || e.contains("boolean")), + "Expected error message referencing None or boolean type, got: {:?}", + errors + ); + } } diff --git a/tests/test_serializer_roundtrip.rs b/tests/test_serializer_roundtrip.rs index cc1c831..eb49ef4 100644 --- a/tests/test_serializer_roundtrip.rs +++ b/tests/test_serializer_roundtrip.rs @@ -181,6 +181,78 @@ topic main: assert_eq!(reparsed.language.as_ref().unwrap().node.entries.len(), 1); } +#[test] +fn test_roundtrip_system_block_with_messages() { + // Covers the `system:` block with both a `messages:` sub-block (welcome + error) + // and a top-level `instructions:` entry. Ensures all three fields survive a + // parse → serialize → parse cycle. + let original = r#"config: + agent_name: "SystemAgent" + +system: + messages: + welcome: "Welcome to our service!" + error: "Something went wrong. Please try again." + instructions: "You are a helpful assistant." + +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("welcome"), "Missing welcome message key"); + assert!(serialized.contains("error"), "Missing error message key"); + + let reparsed = parse(&serialized).expect("Failed to reparse serialized"); + let system = reparsed.system.as_ref().expect("system block lost after roundtrip"); + let messages = system.node.messages.as_ref().expect("messages sub-block lost after roundtrip"); + assert!(messages.node.welcome.is_some(), "welcome message lost after roundtrip"); + assert!(messages.node.error.is_some(), "error message lost after roundtrip"); + assert!(system.node.instructions.is_some(), "instructions lost after roundtrip"); +} + +#[test] +fn test_roundtrip_linked_variable() { + // Covers the `linked` variable kind with a `source:` reference — the serializer + // handles it but no roundtrip test existed. Ensures the kind, type, and source + // survive a parse → serialize → parse cycle. + let original = r#"config: + agent_name: "LinkedVarAgent" + +variables: + user_email: linked string + description: "User email injected from conversation context" + source: @messagingSession.userEmail + +topic main: + description: "Main" +"#; + + let ast = parse(original).expect("Failed to parse original"); + let serialized = serialize(&ast); + + assert!(serialized.contains("linked"), "Missing 'linked' keyword in serialized output"); + assert!(serialized.contains("user_email"), "Missing variable name in serialized output"); + + let reparsed = parse(&serialized).expect("Failed to reparse serialized"); + let vars = &reparsed.variables.as_ref().expect("variables block lost after roundtrip").node; + assert_eq!(vars.variables.len(), 1, "Expected exactly 1 variable after roundtrip"); + let var = &vars.variables[0].node; + assert_eq!( + var.kind, + busbar_sf_agentscript::ast::VariableKind::Linked, + "Expected Linked variable kind after roundtrip" + ); + assert_eq!( + var.ty.node, + busbar_sf_agentscript::Type::String, + "Expected String type after roundtrip" + ); +} + #[test] fn test_roundtrip_before_and_after_reasoning() { // Covers `before_reasoning:` and `after_reasoning:` directive blocks inside a topic.