using System.Text.Json.Nodes; using w4c_workflows.Models.Nodes; using w4c_workflows.Services.Nodes; using Xunit; namespace w4c_workflows.Tests; public class NodeGraphCompilerTests { private const string BranchYaml = """ name: branch-demo tasks: - id: prepare node: { type: core.set } parameters: mode: manual fields: - name: status value: ready next: decide - id: decide node: { type: core.if } parameters: left: "={{ $json.status }}" operator: equals right: ready edges: - { output: 0, to: ok } - { output: 1, to: nope } - id: ok node: { type: core.noop } - id: nope node: { type: core.noop } """; [Fact] public void Compiles_a_node_workflow_and_lowers_next_into_an_edge() { var definition = NodeTestData.Parse(BranchYaml); var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(definition); Assert.True(result.Success, string.Join("\n", result.Errors)); var graph = result.Graph!; Assert.Equal(4, graph.Nodes.Count); Assert.Equal("prepare", graph.EntryNodeId); Assert.Contains( graph.Edges, e => e.FromNodeId == "prepare" && e.FromOutput == 0 && e.ToNodeId == "decide"); Assert.Contains(graph.Edges, e => e.FromNodeId == "decide" && e.FromOutput == 0 && e.ToNodeId == "ok"); Assert.Contains(graph.Edges, e => e.FromNodeId == "decide" && e.FromOutput == 1 && e.ToNodeId == "nope"); } [Fact] public void Parameters_keep_yaml_types_including_nested_mappings() { var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(BranchYaml)); var prepare = result.Graph!.Find("prepare")!; Assert.Equal("manual", prepare.Parameters["mode"]!.GetValue()); var fields = prepare.Parameters["fields"]!.AsArray(); Assert.Single(fields); Assert.Equal("status", fields[0]!["name"]!.GetValue()); Assert.Equal("ready", fields[0]!["value"]!.GetValue()); var decide = result.Graph!.Find("decide")!; Assert.Equal("={{ $json.status }}", decide.Parameters["left"]!.GetValue()); Assert.Equal("equals", decide.Parameters["operator"]!.GetValue()); } [Fact] public void Unknown_node_type_is_reported() { var yaml = """ name: unknown tasks: - id: a node: { type: does.not.exist } """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.False(result.Success); Assert.Contains(result.Errors, e => e.Contains("unknown node type 'does.not.exist'")); } [Fact] public void Unknown_parameter_is_reported() { var yaml = """ name: bad-param tasks: - id: a node: { type: core.set } parameters: nope: 1 """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.False(result.Success); Assert.Contains(result.Errors, e => e.Contains("has no parameter 'nope'")); } [Fact] public void Out_of_range_option_value_is_reported() { var yaml = """ name: bad-option tasks: - id: a node: { type: core.set } parameters: mode: nope """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.False(result.Success); Assert.Contains(result.Errors, e => e.Contains("parameter 'mode' has unsupported value")); } [Fact] public void Expression_parameter_values_skip_static_option_checks() { var yaml = """ name: expr-option tasks: - id: a node: { type: core.set } parameters: mode: "={{ $json.mode }}" """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.True(result.Success, string.Join("\n", result.Errors)); } [Fact] public void A_cycle_is_rejected() { var yaml = """ name: cycle tasks: - id: a node: { type: core.noop } next: b - id: b node: { type: core.noop } next: a """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.False(result.Success); Assert.Contains(result.Errors, e => e.Contains("no entry node") || e.Contains("cycle")); } [Fact] public void A_cycle_through_a_loop_node_is_allowed_and_marked_loop_back() { var yaml = """ name: loop-ok tasks: - id: loop node: { type: core.splitInBatches } parameters: { batchSize: 2 } edges: - { output: 0, to: body } - id: body node: { type: core.noop } edges: - { output: 0, to: loop } """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.True(result.Success, string.Join("\n", result.Errors)); var backEdge = Assert.Single(result.Graph!.Edges.Where(e => e.IsLoopBack)); Assert.Equal("body", backEdge.FromNodeId); Assert.Equal("loop", backEdge.ToNodeId); Assert.Equal("loop", result.Graph!.EntryNodeId); } /// /// P1-2: loop-back classification must not depend on task declaration order. /// Declaring the body before the loop node previously made the DFS classify /// the forward edge (loop → body) as the back edge and reject a valid loop. /// [Fact] public void Loop_back_classification_is_order_independent() { var yaml = """ name: loop-order tasks: - id: body node: { type: core.noop } edges: - { output: 0, to: loop } - id: loop node: { type: core.splitInBatches } parameters: { batchSize: 2 } edges: - { output: 0, to: body } """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.True(result.Success, string.Join("\n", result.Errors)); var backEdge = Assert.Single(result.Graph!.Edges.Where(e => e.IsLoopBack)); Assert.Equal("body", backEdge.FromNodeId); Assert.Equal("loop", backEdge.ToNodeId); Assert.Equal("loop", result.Graph!.EntryNodeId); } [Fact] public void Multiple_entry_nodes_are_rejected() { var yaml = """ name: two-roots tasks: - id: a node: { type: core.noop } - id: b node: { type: core.noop } """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.False(result.Success); Assert.Contains(result.Errors, e => e.Contains("multiple entry nodes")); } [Fact] public void A_node_workflow_step_cannot_mix_node_and_entry() { var yaml = """ name: mixed tasks: - id: a node: { type: core.noop } entry: { file: main.sh } """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.False(result.Success); Assert.Contains(result.Errors, e => e.Contains("mutually exclusive")); } [Fact] public void OnError_lowers_to_the_error_output_port() { var catalog = NodeTestData.Catalog(NodeTestData.NodeBlueprint( "test.fail", outputs: new List { NodePort.Main, NodePort.Error })); var yaml = """ name: error-branch tasks: - id: work node: { type: test.fail } onError: recover - id: recover node: { type: core.noop } """; var result = new NodeGraphCompiler(catalog).Compile(NodeTestData.Parse(yaml)); Assert.True(result.Success, string.Join("\n", result.Errors)); Assert.Contains( result.Graph!.Edges, e => e.FromNodeId == "work" && e.FromOutput == 1 && e.ToNodeId == "recover"); } [Fact] public void OnError_without_an_error_output_is_rejected() { var yaml = """ name: bad-error-branch tasks: - id: work node: { type: core.noop } onError: recover - id: recover node: { type: core.noop } """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.False(result.Success); Assert.Contains(result.Errors, e => e.Contains("has no error output")); } [Fact] public void Pinned_version_resolves_to_that_blueprint() { var catalog = NodeTestData.Catalog( NodeTestData.NodeBlueprint("demo", 1), NodeTestData.NodeBlueprint("demo", 2)); var yaml = """ name: pinned tasks: - id: a node: { type: demo, version: 1 } """; var result = new NodeGraphCompiler(catalog).Compile(NodeTestData.Parse(yaml)); Assert.True(result.Success, string.Join("\n", result.Errors)); Assert.Equal(1, result.Graph!.Find("a")!.Blueprint.Version); } [Fact] public void IsNodeWorkflow_detects_node_mode() { Assert.True(NodeGraphCompiler.IsNodeWorkflow(NodeTestData.Parse(BranchYaml))); var legacy = NodeTestData.Parse(""" name: legacy tasks: - id: a entry: { file: main.sh } """); Assert.False(NodeGraphCompiler.IsNodeWorkflow(legacy)); } // Шаг 9 of the migration playbook: the Slack example YAML must compile and // keep its connector parameters + declared credential alias. [Fact] public void Compiles_the_slack_connector_example_yaml() { var yaml = """ name: notify-on-order tasks: - id: notify node: { type: slack } credentials: { slack: my-bot } parameters: operation: chat.postMessage body: channel: C08514ZPKB8 text: "test message" link_names: true """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.True(result.Success, string.Join("\n", result.Errors)); var notify = result.Graph!.Find("notify")!; Assert.Equal("chat.postMessage", notify.Parameters["operation"]!.GetValue()); Assert.Equal("C08514ZPKB8", notify.Parameters["body"]!["channel"]!.GetValue()); Assert.True(notify.Parameters["body"]!["link_names"]!.GetValue()); Assert.Equal("my-bot", notify.CredentialRefs["slack"]); } [Fact] public void Slack_node_without_its_required_credential_is_reported() { var yaml = """ name: notify-on-order tasks: - id: notify node: { type: slack } parameters: operation: chat.postMessage body: { channel: C1, text: hi } """; var result = new NodeGraphCompiler(NodeTestData.CoreCatalog()).Compile(NodeTestData.Parse(yaml)); Assert.False(result.Success); Assert.Contains(result.Errors, e => e.Contains("requires credential 'slack'")); } }