feat: harden DelimitedParserBlock (JSON fix, error codes, array output, tests)

Executed the full improvement plan for this block, found genuinely
under-baked (zero real usage, schema-only test coverage):

- Fixed a real bug: a JSON-typed output returned the raw trimmed string,
  but Input.validateValue() requires JSON-typed values to be instanceof
  JsonNode - configuring a JSON output was silently broken end to end.
  coerce() now parses the segment with ObjectMapper.readTree(), with a
  clear error on invalid JSON instead of a confusing downstream rejection.
- Replaced raw IllegalArgumentException with NodeExecutionException and
  stable error codes (DELIMITED_PARSER_INPUT_MISSING,
  _SEGMENT_COUNT_MISMATCH, _INVALID_BOOLEAN, _INVALID_FILE,
  _INVALID_JSON), matching the convention already used by
  BranchRejoinExecutor.
- Added an array-output mode: a DelimitedParserOutput (new record, kept
  separate from the shared SwitchCase used by SwitchBlock) marked
  multiple=true collects every split segment into a list with no
  fixed-count check, instead of requiring exactly N named outputs -
  complements the Iterator's "array in" with "array out" here.
- Boolean coercion now also accepts yes/no and 1/0, not just true/false.
- Added DelimitedParserExecutorTest (11 cases, was zero), two end-to-end
  ExecutionTest cases (fixed mode and array mode), and a bundled "test
  delimited parser" example flow (LLM -> DelimitedParserBlock -> EndBlock)
  since the block had never been used in any real flow before. Verified
  live against the running service with a real Ollama call.

No rename needed here - "DelimitedParserBlock" already describes what it
does, unlike ExclusiveMergeBlock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-07-24 20:41:03 +02:00
parent 1fff0d3dc8
commit 1c94cd48d6
9 changed files with 545 additions and 37 deletions

View File

@ -29,7 +29,7 @@ public class DelimitedParserBlockConfiguration extends BlockConfiguration<Delimi
@Size(max = 10)
@Valid
@JsonProperty(required = true)
private List<SwitchCase> outputs = List.of();
private List<DelimitedParserOutput> outputs = List.of();
@UiOrder(20)
@Structural
@ -37,7 +37,7 @@ public class DelimitedParserBlockConfiguration extends BlockConfiguration<Delimi
private String separator;
@Builder
public DelimitedParserBlockConfiguration(@NonNull String name, String separator, List<SwitchCase> outputs) {
public DelimitedParserBlockConfiguration(@NonNull String name, String separator, List<DelimitedParserOutput> outputs) {
super(name);
this.separator = separator;
this.outputs = outputs == null ? List.of() : List.copyOf(outputs);
@ -52,10 +52,16 @@ public class DelimitedParserBlockConfiguration extends BlockConfiguration<Delimi
DelimitedParserBlockConfiguration configuration = new DelimitedParserBlockConfiguration();
configuration.name = DelimitedParserBlockType.TYPE;
configuration.separator = null;
configuration.outputs = List.of(new SwitchCase("part1"), new SwitchCase("part2"));
configuration.outputs = List.of(new DelimitedParserOutput("part1"), new DelimitedParserOutput("part2"));
return configuration;
}
/** Whether this is an array-output configuration: exactly one declared output, marked multiple. */
@JsonIgnore
public boolean isArrayMode() {
return outputs != null && outputs.size() == 1 && outputs.getFirst().multiple();
}
@AssertTrue(message = "separator is required")
@JsonIgnore
boolean isSeparatorValid() {
@ -75,14 +81,27 @@ public class DelimitedParserBlockConfiguration extends BlockConfiguration<Delimi
return true;
}
long distinct = outputs.stream()
.map(SwitchCase::name)
.map(DelimitedParserOutput::name)
.filter(name -> name != null && !name.isBlank())
.distinct()
.count();
long nonBlank = outputs.stream()
.map(SwitchCase::name)
.map(DelimitedParserOutput::name)
.filter(name -> name != null && !name.isBlank())
.count();
return distinct == nonBlank;
}
@AssertTrue(message = "a multiple (array) output must be the only declared output")
@JsonIgnore
boolean isMultipleOutputModeValid() {
if (outputs == null || outputs.isEmpty()) {
return true;
}
long multipleCount = outputs.stream().filter(DelimitedParserOutput::multiple).count();
if (multipleCount == 0) {
return true;
}
return multipleCount == 1 && outputs.size() == 1;
}
}

View File

@ -0,0 +1,36 @@
package it.cnr.isti.workflow.manager.blocks.configurations;
import com.fasterxml.jackson.annotation.JsonProperty;
import it.cnr.isti.workflow.manager.ios.IOType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* One output slot of a {@link DelimitedParserBlockConfiguration}. When
* {@code multiple} is true, this must be the block's only declared output:
* every segment produced by the split goes into it as a list, with no
* fixed-count check. Otherwise it names one fixed-position segment, and the
* block requires exactly as many segments as declared outputs.
*/
public record DelimitedParserOutput(
@NotBlank
@Size(max = 64)
String name,
@JsonProperty(required = false)
IOType type,
@JsonProperty(required = false)
boolean multiple) {
public DelimitedParserOutput(String name) {
this(name, IOType.TEXT, false);
}
public DelimitedParserOutput(String name, IOType type) {
this(name, type, false);
}
public DelimitedParserOutput {
type = type == null ? IOType.TEXT : type;
}
}

View File

@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.IOCapability;
import it.cnr.isti.workflow.manager.blocks.IOCapabilityType;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserOutput;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.types.DelimitedParserBlockType;
import it.cnr.isti.workflow.manager.ios.IODescriptor;
@ -36,12 +36,13 @@ public class DelimitedParserBlockFactory implements BlockFactory<DelimitedParser
.specificConfiguration(configuration)
.type(blockType);
for (SwitchCase outputCase : configuration.getOutputs() == null ? List.<SwitchCase>of() : configuration.getOutputs()) {
for (DelimitedParserOutput outputCase : configuration.getOutputs() == null ? List.<DelimitedParserOutput>of() : configuration.getOutputs()) {
if (outputCase == null || outputCase.name() == null || outputCase.name().isBlank()) {
continue;
}
builder.output(IODescriptor.output(outputCase.name(), outputCase.type(), false,
List.of(new IOCapability(toCapabilityType(outputCase.type()), false))));
builder.output(IODescriptor.output(outputCase.name(), outputCase.type(), outputCase.multiple(),
List.of(new IOCapability(toCapabilityType(outputCase.type()), false),
new IOCapability(toCapabilityType(outputCase.type()), true))));
}
return builder.build();
}

View File

@ -12,24 +12,28 @@ public final class DelimitedResponseParser {
}
public static List<String> parseParts(String payload, String separator, int expectedParts) {
if (!StringUtils.hasText(payload)) {
throw new IllegalArgumentException("Input payload is empty");
}
if (!StringUtils.hasText(separator)) {
throw new IllegalArgumentException("Separator is required");
}
if (expectedParts <= 0) {
throw new IllegalArgumentException("Expected parts must be greater than zero");
}
String[] tokens = payload.split(Pattern.quote(separator), -1);
List<String> parts = Arrays.stream(tokens)
.map(String::trim)
.toList();
List<String> parts = splitAll(payload, separator);
if (parts.size() != expectedParts) {
throw new IllegalArgumentException(
"Expected " + expectedParts + " parts separated by '" + separator + "' but found " + parts.size());
}
return parts;
}
/** Splits the payload on the separator with no count check - every segment is kept, trimmed. */
public static List<String> splitAll(String payload, String separator) {
if (!StringUtils.hasText(payload)) {
throw new IllegalArgumentException("Input payload is empty");
}
if (!StringUtils.hasText(separator)) {
throw new IllegalArgumentException("Separator is required");
}
String[] tokens = payload.split(Pattern.quote(separator), -1);
return Arrays.stream(tokens)
.map(String::trim)
.toList();
}
}

View File

@ -1,20 +1,25 @@
package it.cnr.isti.workflow.manager.executions.executors.blocks;
import java.io.File;
import java.util.List;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserOutput;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.factories.DelimitedParserBlockFactory;
import it.cnr.isti.workflow.manager.blocks.types.DelimitedParserBlockType;
import it.cnr.isti.workflow.manager.executions.ExecutionEventLogger;
import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor;
import it.cnr.isti.workflow.manager.executions.NodeExecutionException;
import it.cnr.isti.workflow.manager.executions.executors.DelimitedResponseParser;
import it.cnr.isti.workflow.manager.executions.steps.Input;
import it.cnr.isti.workflow.manager.ios.IOType;
@ -22,6 +27,8 @@ import it.cnr.isti.workflow.manager.ios.IOType;
@Component
public class DelimitedParserExecutor implements BlockExecutor<DelimitedParserBlockType> {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, Object> execute(Block<DelimitedParserBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
@ -31,14 +38,25 @@ public class DelimitedParserExecutor implements BlockExecutor<DelimitedParserBlo
.filter(input -> DelimitedParserBlockFactory.INPUT_NAME.equals(input.getDescriptor().getName()))
.findFirst()
.map(input -> input.getValue() == null ? null : input.getValue().toString())
.orElseThrow(() -> new IllegalArgumentException("Missing required input: " + DelimitedParserBlockFactory.INPUT_NAME));
.orElseThrow(() -> new NodeExecutionException("DELIMITED_PARSER_INPUT_MISSING",
"Missing required input: " + DelimitedParserBlockFactory.INPUT_NAME));
List<SwitchCase> outputCases = configuration.getOutputs() == null ? List.of() : configuration.getOutputs();
List<String> parts = DelimitedResponseParser.parseParts(payload, configuration.getSeparator(), outputCases.size());
List<DelimitedParserOutput> outputCases = configuration.getOutputs() == null ? List.of() : configuration.getOutputs();
if (configuration.isArrayMode()) {
DelimitedParserOutput outputCase = outputCases.getFirst();
List<String> parts = split(payload, configuration.getSeparator());
List<Object> coerced = new ArrayList<>(parts.size());
for (String part : parts) {
coerced.add(coerce(part, outputCase.type(), outputCase.name()));
}
return Map.of(outputCase.name(), coerced);
}
List<String> parts = parseFixed(payload, configuration.getSeparator(), outputCases.size());
Map<String, Object> result = new LinkedHashMap<>();
for (int index = 0; index < outputCases.size(); index++) {
SwitchCase outputCase = outputCases.get(index);
DelimitedParserOutput outputCase = outputCases.get(index);
result.put(outputCase.name(), coerce(parts.get(index), outputCase.type(), outputCase.name()));
}
return result;
@ -49,22 +67,61 @@ public class DelimitedParserExecutor implements BlockExecutor<DelimitedParserBlo
return DelimitedParserBlockType.class;
}
private List<String> split(String payload, String separator) {
try {
return DelimitedResponseParser.splitAll(payload, separator);
} catch (IllegalArgumentException e) {
throw new NodeExecutionException("DELIMITED_PARSER_SPLIT_FAILED", e.getMessage());
}
}
private List<String> parseFixed(String payload, String separator, int expectedParts) {
try {
return DelimitedResponseParser.parseParts(payload, separator, expectedParts);
} catch (IllegalArgumentException e) {
throw new NodeExecutionException("DELIMITED_PARSER_SEGMENT_COUNT_MISMATCH", e.getMessage());
}
}
private Object coerce(String rawValue, IOType type, String outputName) {
if (type == IOType.BOOLEAN) {
if ("true".equalsIgnoreCase(rawValue)) {
return Boolean.TRUE;
}
if ("false".equalsIgnoreCase(rawValue)) {
return Boolean.FALSE;
}
throw new IllegalArgumentException("Output " + outputName + " expects a boolean value");
return coerceBoolean(rawValue, outputName);
}
if (type == IOType.FILE || type == IOType.CSV) {
if (!StringUtils.hasText(rawValue)) {
throw new IllegalArgumentException("Output " + outputName + " expects a non-empty file path");
throw new NodeExecutionException("DELIMITED_PARSER_INVALID_FILE",
"Output " + outputName + " expects a non-empty file path");
}
return new File(rawValue);
}
if (type == IOType.JSON) {
return coerceJson(rawValue, outputName);
}
return rawValue;
}
}
private Boolean coerceBoolean(String rawValue, String outputName) {
String normalized = rawValue == null ? "" : rawValue.strip().toLowerCase();
if (normalized.equals("true") || normalized.equals("yes") || normalized.equals("1")) {
return Boolean.TRUE;
}
if (normalized.equals("false") || normalized.equals("no") || normalized.equals("0")) {
return Boolean.FALSE;
}
throw new NodeExecutionException("DELIMITED_PARSER_INVALID_BOOLEAN",
"Output " + outputName + " expects a boolean value (true/false, yes/no, 1/0) but found: " + rawValue);
}
private JsonNode coerceJson(String rawValue, String outputName) {
if (!StringUtils.hasText(rawValue)) {
throw new NodeExecutionException("DELIMITED_PARSER_INVALID_JSON",
"Output " + outputName + " expects a non-empty JSON segment");
}
try {
return objectMapper.readTree(rawValue);
} catch (RuntimeException e) {
throw new NodeExecutionException("DELIMITED_PARSER_INVALID_JSON",
"Output " + outputName + " expects a valid JSON segment: " + e.getMessage());
}
}
}

View File

@ -6044,5 +6044,182 @@
],
"dependencies": []
}
},
{
"name": "test delimited parser",
"description": "Delimited-parser example: an LLM produces a pipe-separated assessment (name|assessment|meets-bar), split by DelimitedParserBlock into three typed named outputs (TEXT, TEXT, BOOLEAN).",
"owner": "testuser",
"createdAt": "2026-07-24T12:00:00",
"lastUpdateAt": "2026-07-24T12:00:00",
"published": false,
"finalized": false,
"flow": {
"blocks": [
{
"id": "ad1f5746-8e35-4325-8de1-fccf80fc5178",
"position": {
"x": 0,
"y": 0
},
"name": "collect-candidate-summary",
"inputs": [
{
"name": "input",
"type": "TEXT",
"multiple": false
}
],
"outputs": [
{
"name": "output",
"type": "TEXT",
"multiple": false
}
],
"specificConfiguration": {
"type": "HumanInteractiveBlockConfiguration",
"name": "collect-candidate-summary",
"actionDescription": "Paste the candidate's name and years of relevant experience."
},
"typeName": "HumanInteractionBlock"
},
{
"id": "8c72fa8b-b0e4-4113-8bb0-9e3a0f3c0191",
"position": {
"x": 300,
"y": 0
},
"name": "assess-candidate",
"inputs": [
{
"name": "candidate",
"type": "TEXT",
"multiple": false
}
],
"outputs": [
{
"name": "response",
"type": "TEXT",
"multiple": false
}
],
"specificConfiguration": {
"type": "LLMBlockConfiguration",
"name": "assess-candidate",
"llmDescriptor": {
"provider": "InternalOllama",
"model": "gemma:7b"
},
"prompt": "Given this candidate summary, respond in exactly this format with no extra text: <name>|<one-line assessment>|<true or false for whether they meet a 3+ years experience bar>\n\nCandidate: ${{candidate}}",
"skills": []
},
"typeName": "LLMBlock"
},
{
"id": "68ab5b69-887e-435c-acb9-11262e477632",
"position": {
"x": 600,
"y": 0
},
"name": "parse-assessment",
"inputs": [
{
"name": "input",
"type": "TEXT",
"multiple": false
}
],
"outputs": [
{
"name": "name",
"type": "TEXT",
"multiple": false
},
{
"name": "assessment",
"type": "TEXT",
"multiple": false
},
{
"name": "meetsBar",
"type": "BOOLEAN",
"multiple": false
}
],
"specificConfiguration": {
"type": "DelimitedParserBlockConfiguration",
"name": "parse-assessment",
"separator": "|",
"outputs": [
{
"name": "name",
"type": "TEXT",
"multiple": false
},
{
"name": "assessment",
"type": "TEXT",
"multiple": false
},
{
"name": "meetsBar",
"type": "BOOLEAN",
"multiple": false
}
]
},
"typeName": "DelimitedParserBlock"
},
{
"id": "f1c5273f-0428-459c-9e1a-69554e96e1af",
"position": {
"x": 900,
"y": 0
},
"name": "assessment-recorded",
"inputs": [
{
"name": "input",
"type": "ANY",
"multiple": false
}
],
"outputs": [],
"specificConfiguration": {
"type": "EndBlockConfiguration",
"name": "assessment-recorded",
"outcomeCode": "TEST_DELIMITED_ASSESSMENT_RECORDED",
"outcomeLabel": "Assessment recorded"
},
"typeName": "EndBlock"
}
],
"containers": [],
"connections": [
{
"id": "fc5834f2-6f75-481f-83a3-40b12f87001b",
"sourceId": "ad1f5746-8e35-4325-8de1-fccf80fc5178",
"sourceName": "output",
"targetId": "8c72fa8b-b0e4-4113-8bb0-9e3a0f3c0191",
"targetName": "candidate"
},
{
"id": "a7ffcedc-dcb8-42f9-b6a0-0f42c66a4fa7",
"sourceId": "8c72fa8b-b0e4-4113-8bb0-9e3a0f3c0191",
"sourceName": "response",
"targetId": "68ab5b69-887e-435c-acb9-11262e477632",
"targetName": "input"
},
{
"id": "0be2b2e4-272b-4d3b-ae87-8f96d5590eba",
"sourceId": "68ab5b69-887e-435c-acb9-11262e477632",
"sourceName": "meetsBar",
"targetId": "f1c5273f-0428-459c-9e1a-69554e96e1af",
"targetName": "input"
}
],
"dependencies": []
}
}
]

View File

@ -33,6 +33,7 @@ import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfi
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserOutput;
import it.cnr.isti.workflow.manager.controllers.BlocksController.BlockConfigurationDescriptor;
import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType;
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
@ -880,8 +881,8 @@ public class BlocksControllerTest {
.name("Parser")
.separator("----")
.outputs(List.of(
new SwitchCase("message"),
new SwitchCase("isValid", it.cnr.isti.workflow.manager.ios.IOType.BOOLEAN)))
new DelimitedParserOutput("message"),
new DelimitedParserOutput("isValid", it.cnr.isti.workflow.manager.ios.IOType.BOOLEAN)))
.build());
assertNotNull(block);

View File

@ -35,6 +35,8 @@ import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfigura
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserOutput;
import it.cnr.isti.workflow.manager.containers.Container;
import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration;
import it.cnr.isti.workflow.manager.containers.configurations.GenericContainerConfiguration;
@ -64,6 +66,7 @@ import it.cnr.isti.workflow.manager.blocks.factories.LLMBlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentBlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentChatBlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.SwitchBlockFactory;
import it.cnr.isti.workflow.manager.blocks.factories.DelimitedParserBlockFactory;
import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType;
import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType;
import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType;
@ -71,6 +74,7 @@ import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType;
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType;
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
import it.cnr.isti.workflow.manager.blocks.types.DelimitedParserBlockType;
import it.cnr.isti.workflow.manager.ios.IODescriptor;
import it.cnr.isti.workflow.manager.ios.IOType;
import it.cnr.isti.workflow.manager.llms.ChatMessage;
@ -166,6 +170,9 @@ public class ExecutionTest {
@Autowired
SwitchBlockFactory switchBlockFactory;
@Autowired
DelimitedParserBlockFactory delimitedParserBlockFactory;
@Autowired
HumanInteractiveBlockFactory humanInteractiveBlockFactory;
@ -1708,6 +1715,62 @@ public class ExecutionTest {
&& parentExecutionId.equals(iteration.getParentExecutionId())));
}
@Test
public void delimitedParserBlockSplitsIntoFixedNamedOutputsInARealExecution() {
Block<DelimitedParserBlockType> parser = delimitedParserBlockFactory.create(DelimitedParserBlockConfiguration.builder()
.name("Parser")
.separator("|")
.outputs(List.of(new DelimitedParserOutput("message"), new DelimitedParserOutput("isValid", IOType.BOOLEAN)))
.build());
FlowData flow = FlowData.builder().block(parser).build();
ExecutionObject execObject = executionsService.createExecution("Delimited parser flow", flow);
execObject = executionsService.prepareInput(execObject.getId(), parser.getId(), "input", "hello world|true");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
execObject = executionsService.getExecution(execObject.getId());
}
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
assertEquals("hello world", execObject.getContext().getResult().get(new FieldKey(parser.getId(), "message")));
assertEquals(true, execObject.getContext().getResult().get(new FieldKey(parser.getId(), "isValid")));
}
@Test
public void delimitedParserBlockArrayModeCollectsEverySegmentInARealExecution() {
Block<DelimitedParserBlockType> parser = delimitedParserBlockFactory.create(DelimitedParserBlockConfiguration.builder()
.name("Parser")
.separator(",")
.outputs(List.of(new DelimitedParserOutput("items", IOType.TEXT, true)))
.build());
FlowData flow = FlowData.builder().block(parser).build();
ExecutionObject execObject = executionsService.createExecution("Delimited parser array flow", flow);
execObject = executionsService.prepareInput(execObject.getId(), parser.getId(), "input", "alpha,beta,gamma,delta");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
execObject = executionsService.getExecution(execObject.getId());
}
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
assertEquals(List.of("alpha", "beta", "gamma", "delta"),
execObject.getContext().getResult().get(new FieldKey(parser.getId(), "items")));
}
@Test
public void containerOutputRejectedByDownstreamInputFailsCleanlyInsteadOfHanging() {
Block<LLMBlockType> internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()

View File

@ -0,0 +1,150 @@
package it.cnr.isti.workflow.manager.executions.executors.blocks;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.File;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.JsonNode;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.DelimitedParserOutput;
import it.cnr.isti.workflow.manager.blocks.types.DelimitedParserBlockType;
import it.cnr.isti.workflow.manager.executions.NodeExecutionException;
import it.cnr.isti.workflow.manager.executions.steps.Input;
import it.cnr.isti.workflow.manager.ios.IODescriptor;
import it.cnr.isti.workflow.manager.ios.IOType;
class DelimitedParserExecutorTest {
private final DelimitedParserExecutor executor = new DelimitedParserExecutor();
@Test
void splitsAFixedNumberOfNamedOutputs() {
Map<String, Object> result = executor.execute(
block("|", List.of(new DelimitedParserOutput("message"),
new DelimitedParserOutput("isValid", IOType.BOOLEAN))),
List.of(input("hello|true")), Map.of(), Map.of(), Map.of(), null);
assertEquals("hello", result.get("message"));
assertEquals(Boolean.TRUE, result.get("isValid"));
}
@Test
void rejectsAMismatchedSegmentCount() {
NodeExecutionException exception = assertThrows(NodeExecutionException.class,
() -> executor.execute(
block("|", List.of(new DelimitedParserOutput("a"), new DelimitedParserOutput("b"))),
List.of(input("only-one")), Map.of(), Map.of(), Map.of(), null));
assertEquals("DELIMITED_PARSER_SEGMENT_COUNT_MISMATCH", exception.getErrorCode());
}
@Test
void rejectsAMissingInput() {
NodeExecutionException exception = assertThrows(NodeExecutionException.class,
() -> executor.execute(block("|", List.of(new DelimitedParserOutput("a"))),
List.of(), Map.of(), Map.of(), Map.of(), null));
assertEquals("DELIMITED_PARSER_INPUT_MISSING", exception.getErrorCode());
}
@Test
void coercesBooleanFromSeveralAcceptedSpellings() {
Map<String, Object> yes = executor.execute(block("|", List.of(new DelimitedParserOutput("flag", IOType.BOOLEAN))),
List.of(input("yes")), Map.of(), Map.of(), Map.of(), null);
Map<String, Object> zero = executor.execute(block("|", List.of(new DelimitedParserOutput("flag", IOType.BOOLEAN))),
List.of(input("0")), Map.of(), Map.of(), Map.of(), null);
assertEquals(Boolean.TRUE, yes.get("flag"));
assertEquals(Boolean.FALSE, zero.get("flag"));
}
@Test
void rejectsAnUnrecognizedBooleanSpelling() {
NodeExecutionException exception = assertThrows(NodeExecutionException.class,
() -> executor.execute(block("|", List.of(new DelimitedParserOutput("flag", IOType.BOOLEAN))),
List.of(input("maybe")), Map.of(), Map.of(), Map.of(), null));
assertEquals("DELIMITED_PARSER_INVALID_BOOLEAN", exception.getErrorCode());
}
@Test
void coercesFileSegmentsToFile() {
Map<String, Object> result = executor.execute(
block("|", List.of(new DelimitedParserOutput("path", IOType.FILE))),
List.of(input("/tmp/report.txt")), Map.of(), Map.of(), Map.of(), null);
assertInstanceOf(File.class, result.get("path"));
}
@Test
void rejectsAnEmptyFileSegment() {
NodeExecutionException exception = assertThrows(NodeExecutionException.class,
() -> executor.execute(
block("|", List.of(new DelimitedParserOutput("label"), new DelimitedParserOutput("path", IOType.FILE))),
List.of(input("x| ")), Map.of(), Map.of(), Map.of(), null));
assertEquals("DELIMITED_PARSER_INVALID_FILE", exception.getErrorCode());
}
@Test
void coercesAValidJsonSegmentToAJsonNode() {
Map<String, Object> result = executor.execute(
block("~", List.of(new DelimitedParserOutput("payload", IOType.JSON))),
List.of(input("{\"ok\":true}")), Map.of(), Map.of(), Map.of(), null);
JsonNode node = assertInstanceOf(JsonNode.class, result.get("payload"));
assertTrue(node.path("ok").asBoolean());
}
@Test
void rejectsAnInvalidJsonSegment() {
NodeExecutionException exception = assertThrows(NodeExecutionException.class,
() -> executor.execute(block("~", List.of(new DelimitedParserOutput("payload", IOType.JSON))),
List.of(input("{not json")), Map.of(), Map.of(), Map.of(), null));
assertEquals("DELIMITED_PARSER_INVALID_JSON", exception.getErrorCode());
}
@Test
void arrayModeCollectsEverySegmentRegardlessOfCount() {
Map<String, Object> result = executor.execute(
block(",", List.of(new DelimitedParserOutput("items", IOType.TEXT, true))),
List.of(input("a,b,c,d")), Map.of(), Map.of(), Map.of(), null);
assertEquals(List.of("a", "b", "c", "d"), result.get("items"));
}
@Test
void arrayModeCoercesEveryElementToTheDeclaredType() {
Map<String, Object> result = executor.execute(
block(",", List.of(new DelimitedParserOutput("flags", IOType.BOOLEAN, true))),
List.of(input("true,false,yes,no")), Map.of(), Map.of(), Map.of(), null);
assertEquals(List.of(true, false, true, false), result.get("flags"));
}
private Input input(String payload) {
return Input.detached(IODescriptor.of("input", IOType.TEXT), payload);
}
private Block<DelimitedParserBlockType> block(String separator, List<DelimitedParserOutput> outputs) {
DelimitedParserBlockConfiguration configuration = DelimitedParserBlockConfiguration.builder()
.name("parser")
.separator(separator)
.outputs(outputs)
.build();
return Block.<DelimitedParserBlockType>builder()
.specificConfiguration(configuration)
.type(new DelimitedParserBlockType())
.build();
}
}