359 lines
14 KiB
C#
359 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<FlowItem>[] { 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<FlowItem> 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<string>()).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sort_reads_array_elements_in_a_path()
|
|
{
|
|
// S4: the unified resolver supports [index], so items[0].id is usable in
|
|
// sort/dedup/aggregate instead of silently resolving to null.
|
|
var outcome = await new SortNodeExecutor().RunAsync(
|
|
Context("core.sort", new JsonObject
|
|
{
|
|
["sortFields"] = FixedCollection(new JsonObject { ["fieldName"] = "items[0].id" }),
|
|
}, Batch("""{"items":[{"id":3}]}""", """{"items":[{"id":1}]}""", """{"items":[{"id":2}]}""")), default);
|
|
|
|
Assert.Equal(
|
|
new[] { "1", "2", "3" },
|
|
outcome.Outputs[0].Select(i => i.Json["items"]![0]!["id"]!.GetValue<int>().ToString()).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<string>()).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<string>());
|
|
}
|
|
|
|
[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<string>());
|
|
Assert.Equal(2, outcome.Outputs[0][0].Json["count"]!.GetValue<int>());
|
|
Assert.Equal(1, outcome.Outputs[0][1].Json["count"]!.GetValue<int>());
|
|
}
|
|
|
|
[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<double>());
|
|
Assert.Equal(10d, outcome.Outputs[0][1].Json["total"]!.GetValue<double>());
|
|
}
|
|
|
|
[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<double>());
|
|
Assert.Equal(2d, item.Json["lo"]!.GetValue<double>());
|
|
Assert.Equal(6d, item.Json["hi"]!.GetValue<double>());
|
|
}
|
|
|
|
[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<int>());
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|