diff --git a/src/VolcanicCore.Nodes/PythonTransformNode.cs b/src/VolcanicCore.Nodes/PythonTransformNode.cs index 71169d2..d2773e2 100644 --- a/src/VolcanicCore.Nodes/PythonTransformNode.cs +++ b/src/VolcanicCore.Nodes/PythonTransformNode.cs @@ -59,12 +59,19 @@ public sealed class PythonTransformNode : IActionNode string.IsNullOrEmpty(formatted) ? ex.Message : formatted); } - if (!scope.TryGetVariable("output", out object? outputObj)) + if (!scope.TryGetVariable("output", out object? outputObj) || outputObj is null) return Task.FromResult>>(new[] { Array.Empty() }); + // Fan out only when `output` serializes to a JSON array. A naive + // `IEnumerable` check would treat a bare string as an iterable of chars + // (`output = "hello"` → 5 items) and a bare dict as an iterable of keys. + // Serializing first and dispatching on ValueKind gives one predictable rule. + var element = JsonToPython.ToJson(outputObj); var outItems = new List(); - if (outputObj is System.Collections.IEnumerable ie) - foreach (var v in ie) outItems.Add(new Item(JsonToPython.ToJson(v))); + if (element.ValueKind == JsonValueKind.Array) + foreach (var el in element.EnumerateArray()) outItems.Add(new Item(el.Clone())); + else + outItems.Add(new Item(element)); return Task.FromResult>>(new[] { outItems }); } diff --git a/tests/VolcanicCore.Tests/PythonTransformNodeTests.cs b/tests/VolcanicCore.Tests/PythonTransformNodeTests.cs index 3624190..580599f 100644 --- a/tests/VolcanicCore.Tests/PythonTransformNodeTests.cs +++ b/tests/VolcanicCore.Tests/PythonTransformNodeTests.cs @@ -73,6 +73,51 @@ public class PythonTransformNodeTests 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[] { Array.Empty() }, 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[] { Array.Empty() }, 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[] { Array.Empty() }, 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[] { Array.Empty() }, CancellationToken.None); + Assert.Single(outs); + Assert.Empty(outs[0]); + } + [Fact] public async Task Syntax_error_message_contains_python_traceback() {