The old code iterated every IEnumerable, so `output = "hello"` emitted one
item per character (a string is IEnumerable-of-char) and `output = {"a":1}`
emitted the keys as items. Now the script's output is serialized first and
the ValueKind decides: JSON array → per-element items; anything else →
single item. `output = None` remains an empty emission.
New tests cover string, dict, number, and None shapes.
132 lines
5.1 KiB
C#
132 lines
5.1 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using VolcanicCore.Api.Engine;
|
|
using VolcanicCore.Core;
|
|
using VolcanicCore.Nodes;
|
|
|
|
// ImplicitUsings pulls System.Threading → collision with our ExecutionContext.
|
|
using ExecutionContext = VolcanicCore.Api.Engine.ExecutionContext;
|
|
|
|
namespace VolcanicCore.Tests;
|
|
|
|
public class PythonTransformNodeTests
|
|
{
|
|
private sealed class NullReg : IConnectionInstanceRegistry
|
|
{
|
|
public Task<object> GetAsync(string id, CancellationToken ct) => throw new InvalidOperationException();
|
|
}
|
|
|
|
private static ExecutionContext Ctx(string script) => new(
|
|
"e", "wf", "n",
|
|
JsonDocument.Parse(JsonSerializer.Serialize(new { script })).RootElement,
|
|
null, NullLogger.Instance, null, new NullReg());
|
|
|
|
[Fact]
|
|
public async Task Doubles_each_items_value_field()
|
|
{
|
|
var node = new PythonTransformNode();
|
|
var script = "output = [{'value': item['value'] * 2} for item in items]";
|
|
var input = new IReadOnlyList<Item>[]
|
|
{
|
|
new [] {
|
|
new Item(JsonDocument.Parse("""{"value":3}""").RootElement.Clone()),
|
|
new Item(JsonDocument.Parse("""{"value":7}""").RootElement.Clone()),
|
|
}
|
|
};
|
|
var outs = await node.ExecuteAsync(Ctx(script), input, CancellationToken.None);
|
|
Assert.Single(outs);
|
|
Assert.Equal(2, outs[0].Count);
|
|
Assert.Equal(6, outs[0][0].Json.GetProperty("value").GetInt32());
|
|
Assert.Equal(14, outs[0][1].Json.GetProperty("value").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Missing_output_variable_yields_empty_list()
|
|
{
|
|
var node = new PythonTransformNode();
|
|
var outs = await node.ExecuteAsync(Ctx("x = 1"),
|
|
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
|
|
Assert.Single(outs);
|
|
Assert.Empty(outs[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Python_exception_propagates_as_node_failure()
|
|
{
|
|
var node = new PythonTransformNode();
|
|
var input = new IReadOnlyList<Item>[] { new [] { new Item(JsonDocument.Parse("{}").RootElement) } };
|
|
await Assert.ThrowsAnyAsync<Exception>(() =>
|
|
node.ExecuteAsync(Ctx("raise ValueError('bad')"), input, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Runtime_error_message_contains_python_traceback_with_line_and_type()
|
|
{
|
|
var node = new PythonTransformNode();
|
|
var input = new IReadOnlyList<Item>[] { new [] { new Item(JsonDocument.Parse("{}").RootElement) } };
|
|
var script = "x = 1\noutput = [notdefined + 1]";
|
|
var ex = await Assert.ThrowsAnyAsync<Exception>(() =>
|
|
node.ExecuteAsync(Ctx(script), input, CancellationToken.None));
|
|
Assert.Contains("Traceback", ex.Message);
|
|
Assert.Contains("NameError", ex.Message);
|
|
Assert.Contains("notdefined", ex.Message);
|
|
Assert.Contains("line 2", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bare_string_output_is_a_single_item_not_one_char_per_item()
|
|
{
|
|
var node = new PythonTransformNode();
|
|
var outs = await node.ExecuteAsync(Ctx("output = 'hello'"),
|
|
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
|
|
Assert.Single(outs);
|
|
Assert.Single(outs[0]);
|
|
Assert.Equal("hello", outs[0][0].Json.GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bare_dict_output_is_a_single_item_not_one_key_per_item()
|
|
{
|
|
var node = new PythonTransformNode();
|
|
var outs = await node.ExecuteAsync(Ctx("output = {'test': 'hi', 'n': 3}"),
|
|
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
|
|
Assert.Single(outs);
|
|
Assert.Single(outs[0]);
|
|
Assert.Equal(JsonValueKind.Object, outs[0][0].Json.ValueKind);
|
|
Assert.Equal("hi", outs[0][0].Json.GetProperty("test").GetString());
|
|
Assert.Equal(3, outs[0][0].Json.GetProperty("n").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bare_number_output_is_a_single_item()
|
|
{
|
|
var node = new PythonTransformNode();
|
|
var outs = await node.ExecuteAsync(Ctx("output = 42"),
|
|
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
|
|
Assert.Single(outs);
|
|
Assert.Single(outs[0]);
|
|
Assert.Equal(42, outs[0][0].Json.GetInt64());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task None_output_yields_empty_list()
|
|
{
|
|
var node = new PythonTransformNode();
|
|
var outs = await node.ExecuteAsync(Ctx("output = None"),
|
|
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
|
|
Assert.Single(outs);
|
|
Assert.Empty(outs[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Syntax_error_message_contains_python_traceback()
|
|
{
|
|
var node = new PythonTransformNode();
|
|
var input = new IReadOnlyList<Item>[] { new [] { new Item(JsonDocument.Parse("{}").RootElement) } };
|
|
var script = "output = [";
|
|
var ex = await Assert.ThrowsAnyAsync<Exception>(() =>
|
|
node.ExecuteAsync(Ctx(script), input, CancellationToken.None));
|
|
Assert.Contains("SyntaxError", ex.Message);
|
|
}
|
|
}
|