using System.Text.Json.Nodes; using w4c_workflows.Models.Nodes; using w4c_workflows.Services.Nodes; using w4c_workflows.Services.Nodes.Executors; using Xunit; namespace w4c_workflows.Tests; /// /// Unit tests for the item-shaping core nodes (limit / sort / remove duplicates / /// aggregate). All four run in all-items mode, so each context receives the whole /// batch on input 0. /// public class TransformNodeExecutorTests { private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog(); private static FlowItem Item(string json) => FlowItem.FromJson(JsonNode.Parse(json)!.AsObject()); private static FlowItem[] Batch(params string[] items) => items.Select(Item).ToArray(); private static NodeExecutionContext Context(string type, JsonObject parameters, params FlowItem[] inputs) => new() { Blueprint = Catalog.Get(type)!, Parameters = parameters, Inputs = new IReadOnlyList[] { inputs }, }; private static JsonArray FixedCollection(params JsonObject[] entries) { var array = new JsonArray(); foreach (var entry in entries) array.Add(entry); return array; } private static string[] Values(IReadOnlyList items, string field) => items.Select(item => item.Json[field]?.ToString() ?? "null").ToArray(); // ------------------------------------------------------------------ limit [Fact] public async Task Limit_keeps_the_first_items_by_default() { var outcome = await new LimitNodeExecutor().RunAsync( Context("core.limit", new JsonObject { ["maxItems"] = 2 }, Batch("""{"n":1}""", """{"n":2}""", """{"n":3}""")), default); Assert.Equal(new[] { "1", "2" }, Values(outcome.Outputs[0], "n")); } [Fact] public async Task Limit_keeps_the_last_items_when_asked() { var outcome = await new LimitNodeExecutor().RunAsync( Context("core.limit", new JsonObject { ["maxItems"] = 2, ["keep"] = "lastItems" }, Batch("""{"n":1}""", """{"n":2}""", """{"n":3}""")), default); Assert.Equal(new[] { "2", "3" }, Values(outcome.Outputs[0], "n")); } [Fact] public async Task Limit_respects_a_zero_cap() { var outcome = await new LimitNodeExecutor().RunAsync( Context("core.limit", new JsonObject { ["maxItems"] = 0 }, Batch("""{"n":1}""")), default); Assert.Empty(outcome.Outputs[0]); } [Fact] public async Task Limit_rejects_a_negative_cap() { var outcome = await new LimitNodeExecutor().RunAsync( Context("core.limit", new JsonObject { ["maxItems"] = -1 }, Batch("""{"n":1}""")), default); Assert.False(outcome.Succeeded); Assert.Equal("invalid_parameter", outcome.Failure!.Code); } // ------------------------------------------------------------------ sort [Fact] public async Task Sort_orders_numbers_ascending_by_default() { var outcome = await new SortNodeExecutor().RunAsync( Context("core.sort", new JsonObject { ["sortFields"] = FixedCollection(new JsonObject { ["fieldName"] = "n" }), }, Batch("""{"n":3}""", """{"n":1}""", """{"n":2}""")), default); Assert.Equal(new[] { "1", "2", "3" }, Values(outcome.Outputs[0], "n")); } [Fact] public async Task Sort_orders_descending_when_the_direction_says_so() { var outcome = await new SortNodeExecutor().RunAsync( Context("core.sort", new JsonObject { ["sortFields"] = FixedCollection(new JsonObject { ["fieldName"] = "n", ["direction"] = "descend" }), }, Batch("""{"n":1}""", """{"n":3}""", """{"n":2}""")), default); Assert.Equal(new[] { "3", "2", "1" }, Values(outcome.Outputs[0], "n")); } [Fact] public async Task Sort_breaks_ties_with_the_next_field() { var outcome = await new SortNodeExecutor().RunAsync( Context("core.sort", new JsonObject { ["sortFields"] = FixedCollection( new JsonObject { ["fieldName"] = "grp" }, new JsonObject { ["fieldName"] = "n", ["direction"] = "descend" }), }, Batch( """{"grp":"a","n":1}""", """{"grp":"b","n":9}""", """{"grp":"a","n":5}""")), default); Assert.Equal(new[] { "5", "1", "9" }, Values(outcome.Outputs[0], "n")); } [Fact] public async Task Sort_puts_missing_values_last_whatever_the_direction() { var ascending = await new SortNodeExecutor().RunAsync( Context("core.sort", new JsonObject { ["sortFields"] = FixedCollection(new JsonObject { ["fieldName"] = "n" }), }, Batch("""{"n":2}""", """{"other":0}""", """{"n":1}""")), default); Assert.Equal(new[] { "1", "2", "null" }, Values(ascending.Outputs[0], "n")); var descending = await new SortNodeExecutor().RunAsync( Context("core.sort", new JsonObject { ["sortFields"] = FixedCollection(new JsonObject { ["fieldName"] = "n", ["direction"] = "descend" }), }, Batch("""{"n":2}""", """{"other":0}""", """{"n":1}""")), default); Assert.Equal(new[] { "2", "1", "null" }, Values(descending.Outputs[0], "n")); } [Fact] public async Task Sort_reads_nested_fields_with_dot_notation() { var outcome = await new SortNodeExecutor().RunAsync( Context("core.sort", new JsonObject { ["sortFields"] = FixedCollection(new JsonObject { ["fieldName"] = "user.name" }), }, Batch("""{"user":{"name":"b"}}""", """{"user":{"name":"a"}}""")), default); Assert.Equal(new[] { "a", "b" }, outcome.Outputs[0].Select(i => i.Json["user"]!["name"]!.GetValue()).ToArray()); } [Fact] public async Task Sort_orders_dates_chronologically() { var outcome = await new SortNodeExecutor().RunAsync( Context("core.sort", new JsonObject { ["sortFields"] = FixedCollection(new JsonObject { ["fieldName"] = "at" }), ["type"] = "dateTime", }, Batch("""{"at":"2024-03-02"}""", """{"at":"2024-01-05"}""", """{"at":"2024-02-01"}""")), default); Assert.Equal( new[] { "2024-01-05", "2024-02-01", "2024-03-02" }, outcome.Outputs[0].Select(i => i.Json["at"]!.GetValue()).ToArray()); } // ------------------------------------------------------------------ remove duplicates [Fact] public async Task RemoveDuplicates_keeps_the_first_occurrence_by_default() { var outcome = await new RemoveDuplicatesNodeExecutor().RunAsync( Context("core.removeDuplicates", new JsonObject { ["compare"] = "allFields" }, Batch("""{"id":1,"v":"a"}""", """{"id":1,"v":"a"}""", """{"id":2,"v":"b"}""")), default); Assert.Equal(2, outcome.Outputs[0].Count); Assert.Equal(new[] { "1", "2" }, Values(outcome.Outputs[0], "id")); } [Fact] public async Task RemoveDuplicates_ignores_property_order_when_comparing() { var outcome = await new RemoveDuplicatesNodeExecutor().RunAsync( Context("core.removeDuplicates", new JsonObject { ["compare"] = "allFields" }, Batch("""{"a":1,"b":2}""", """{"b":2,"a":1}""")), default); Assert.Single(outcome.Outputs[0]); } [Fact] public async Task RemoveDuplicates_keeps_the_last_occurrence_when_asked() { var outcome = await new RemoveDuplicatesNodeExecutor().RunAsync( Context("core.removeDuplicates", new JsonObject { ["compare"] = "selectedFields", ["fields"] = FixedCollection(new JsonObject { ["fieldName"] = "id" }), ["keep"] = "lastItems", }, Batch("""{"id":1,"v":"first"}""", """{"id":1,"v":"last"}""")), default); var kept = Assert.Single(outcome.Outputs[0]); Assert.Equal("last", kept.Json["v"]!.GetValue()); } [Fact] public async Task RemoveDuplicates_can_compare_only_selected_fields() { var outcome = await new RemoveDuplicatesNodeExecutor().RunAsync( Context("core.removeDuplicates", new JsonObject { ["compare"] = "selectedFields", ["fields"] = FixedCollection(new JsonObject { ["fieldName"] = "id" }), }, Batch("""{"id":1,"v":"a"}""", """{"id":1,"v":"b"}""", """{"id":2,"v":"a"}""")), default); Assert.Equal(2, outcome.Outputs[0].Count); Assert.Equal(new[] { "1", "2" }, Values(outcome.Outputs[0], "id")); } [Fact] public async Task RemoveDuplicates_can_compare_all_fields_except_selected() { var outcome = await new RemoveDuplicatesNodeExecutor().RunAsync( Context("core.removeDuplicates", new JsonObject { ["compare"] = "allFieldsExcept", ["fields"] = FixedCollection(new JsonObject { ["fieldName"] = "v" }), }, Batch("""{"id":1,"v":"a"}""", """{"id":1,"v":"b"}""", """{"id":2,"v":"a"}""")), default); Assert.Equal(2, outcome.Outputs[0].Count); Assert.Equal(new[] { "1", "2" }, Values(outcome.Outputs[0], "id")); } [Fact] public async Task RemoveDuplicates_requires_a_field_for_selected_comparison() { var outcome = await new RemoveDuplicatesNodeExecutor().RunAsync( Context("core.removeDuplicates", new JsonObject { ["compare"] = "selectedFields" }, Batch("""{"id":1}""")), default); Assert.False(outcome.Succeeded); Assert.Equal("invalid_parameter", outcome.Failure!.Code); } // ------------------------------------------------------------------ aggregate [Fact] public async Task Aggregate_all_item_data_folds_the_batch_into_one_item() { var outcome = await new AggregateNodeExecutor().RunAsync( Context("core.aggregate", new JsonObject { ["mode"] = "allItemData", ["destinationFieldName"] = "rows", }, Batch("""{"n":1}""", """{"n":2}""")), default); var item = Assert.Single(outcome.Outputs[0]); Assert.Equal(2, item.Json["rows"]!.AsArray().Count); } [Fact] public async Task Aggregate_groups_and_counts_by_the_remaining_fields() { var outcome = await new AggregateNodeExecutor().RunAsync( Context("core.aggregate", new JsonObject { ["mode"] = "individualFields", ["fields"] = FixedCollection(new JsonObject { ["aggregation"] = "count" }), }, Batch("""{"grp":"a"}""", """{"grp":"a"}""", """{"grp":"b"}""")), default); Assert.Equal(2, outcome.Outputs[0].Count); Assert.Equal("a", outcome.Outputs[0][0].Json["grp"]!.GetValue()); Assert.Equal(2, outcome.Outputs[0][0].Json["count"]!.GetValue()); Assert.Equal(1, outcome.Outputs[0][1].Json["count"]!.GetValue()); } [Fact] public async Task Aggregate_sums_a_selected_field_per_group() { var outcome = await new AggregateNodeExecutor().RunAsync( Context("core.aggregate", new JsonObject { ["fields"] = FixedCollection( new JsonObject { ["fieldName"] = "amount", ["aggregation"] = "sum", ["renameField"] = "total" }), }, Batch( """{"grp":"a","amount":1}""", """{"grp":"a","amount":3}""", """{"grp":"b","amount":10}""")), default); Assert.Equal(4d, outcome.Outputs[0][0].Json["total"]!.GetValue()); Assert.Equal(10d, outcome.Outputs[0][1].Json["total"]!.GetValue()); } [Fact] public async Task Aggregate_computes_average_min_and_max() { var outcome = await new AggregateNodeExecutor().RunAsync( Context("core.aggregate", new JsonObject { ["fields"] = FixedCollection( new JsonObject { ["fieldName"] = "amount", ["aggregation"] = "average", ["renameField"] = "avg" }, new JsonObject { ["fieldName"] = "amount", ["aggregation"] = "min", ["renameField"] = "lo" }, new JsonObject { ["fieldName"] = "amount", ["aggregation"] = "max", ["renameField"] = "hi" }), }, Batch("""{"grp":"a","amount":2}""", """{"grp":"a","amount":6}""")), default); var item = Assert.Single(outcome.Outputs[0]); Assert.Equal(4d, item.Json["avg"]!.GetValue()); Assert.Equal(2d, item.Json["lo"]!.GetValue()); Assert.Equal(6d, item.Json["hi"]!.GetValue()); } [Fact] public async Task Aggregate_without_fields_counts_the_whole_batch() { var outcome = await new AggregateNodeExecutor().RunAsync( Context("core.aggregate", new JsonObject { ["fields"] = new JsonArray() }, Batch("""{"n":1}""", """{"n":2}""", """{"n":3}""")), default); var item = Assert.Single(outcome.Outputs[0]); Assert.Equal(3, item.Json["count"]!.GetValue()); } [Fact] public async Task Aggregate_requires_a_field_for_non_count_aggregations() { var outcome = await new AggregateNodeExecutor().RunAsync( Context("core.aggregate", new JsonObject { ["fields"] = FixedCollection(new JsonObject { ["aggregation"] = "sum" }), }, Batch("""{"n":1}""")), default); Assert.False(outcome.Succeeded); Assert.Equal("invalid_parameter", outcome.Failure!.Code); } }