w4c-workflows-api/w4c-workflows-api.Tests/NodeGraphCompilerTests.cs

381 lines
12 KiB
C#
Raw Normal View History

2026-09-11 22:02:46 +00:00
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<string>());
var fields = prepare.Parameters["fields"]!.AsArray();
Assert.Single(fields);
Assert.Equal("status", fields[0]!["name"]!.GetValue<string>());
Assert.Equal("ready", fields[0]!["value"]!.GetValue<string>());
var decide = result.Graph!.Find("decide")!;
Assert.Equal("={{ $json.status }}", decide.Parameters["left"]!.GetValue<string>());
Assert.Equal("equals", decide.Parameters["operator"]!.GetValue<string>());
}
[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);
}
2026-09-13 08:35:17 +00:00
/// <summary>
/// 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.
/// </summary>
[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);
}
2026-09-11 22:02:46 +00:00
[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> { 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));
}
2026-09-13 16:28:47 +00:00
// Шаг 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<string>());
Assert.Equal("C08514ZPKB8", notify.Parameters["body"]!["channel"]!.GetValue<string>());
Assert.True(notify.Parameters["body"]!["link_names"]!.GetValue<bool>());
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'"));
}
2026-09-11 22:02:46 +00:00
}