Files
VolcanicCore/tests/VolcanicCore.Tests/PythonTransformNodeTests.cs
Claude 6615c5f7d0 fix(python): fan out only on JSON-array output, not any IEnumerable
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.
2026-08-31 11:21:55 +00:00

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