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);
}
}