diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/BlockType.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/BlockType.java index ee3158c..0c57dd8 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/BlockType.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/BlockType.java @@ -3,6 +3,7 @@ package it.cnr.isti.workflow.manager.blocks.types; import com.fasterxml.jackson.annotation.JsonIgnore; import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; public interface BlockType { @@ -14,6 +15,10 @@ public interface BlockType { boolean isUserInteractive(); + default NodeTypeCapabilities getCapabilities() { + return NodeTypeCapabilities.activity(); + } + @JsonIgnore Class> getBlockConfigurationClass(); diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/ConditionalBlockType.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/ConditionalBlockType.java index 6a99229..85c04a7 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/ConditionalBlockType.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/ConditionalBlockType.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Component; import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.ConditionalBlockConfiguration; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; @Component(ConditionalBlockType.TYPE) public class ConditionalBlockType implements BlockType { @@ -30,6 +31,11 @@ public class ConditionalBlockType implements BlockType { return false; } + @Override + public NodeTypeCapabilities getCapabilities() { + return NodeTypeCapabilities.decision(); + } + @Override public Class> getBlockConfigurationClass() { return ConditionalBlockConfiguration.class; diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/EndBlockType.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/EndBlockType.java index 6d22404..d0b7c07 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/EndBlockType.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/EndBlockType.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Component; import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.EndBlockConfiguration; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; @Component(EndBlockType.TYPE) public class EndBlockType implements BlockType { @@ -30,6 +31,11 @@ public class EndBlockType implements BlockType { return false; } + @Override + public NodeTypeCapabilities getCapabilities() { + return NodeTypeCapabilities.end(); + } + @Override public Class> getBlockConfigurationClass() { return EndBlockConfiguration.class; diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/ExclusiveMergeBlockType.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/ExclusiveMergeBlockType.java index f9e73b6..0f1cd2a 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/ExclusiveMergeBlockType.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/ExclusiveMergeBlockType.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Component; import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.ExclusiveMergeBlockConfiguration; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; @Component(ExclusiveMergeBlockType.TYPE) public class ExclusiveMergeBlockType implements BlockType { @@ -30,6 +31,11 @@ public class ExclusiveMergeBlockType implements BlockType { return false; } + @Override + public NodeTypeCapabilities getCapabilities() { + return NodeTypeCapabilities.merge(); + } + @Override public Class> getBlockConfigurationClass() { return ExclusiveMergeBlockConfiguration.class; diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/HumanDecisionBlockType.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/HumanDecisionBlockType.java index 5531c04..7691d9e 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/HumanDecisionBlockType.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/HumanDecisionBlockType.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Component; import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.HumanDecisionBlockConfiguration; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; @Component(HumanDecisionBlockType.TYPE) public class HumanDecisionBlockType implements BlockType { @@ -30,6 +31,11 @@ public class HumanDecisionBlockType implements BlockType { return true; } + @Override + public NodeTypeCapabilities getCapabilities() { + return NodeTypeCapabilities.decision(); + } + @Override public Class> getBlockConfigurationClass() { return HumanDecisionBlockConfiguration.class; diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/SwitchBlockType.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/SwitchBlockType.java index 8c54b95..7732f48 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/SwitchBlockType.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/SwitchBlockType.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Component; import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; @Component(SwitchBlockType.TYPE) public class SwitchBlockType implements BlockType { @@ -30,6 +31,11 @@ public class SwitchBlockType implements BlockType { return false; } + @Override + public NodeTypeCapabilities getCapabilities() { + return NodeTypeCapabilities.decision(); + } + @Override public Class> getBlockConfigurationClass() { return SwitchBlockConfiguration.class; diff --git a/src/main/java/it/cnr/isti/workflow/manager/containers/types/ContainerType.java b/src/main/java/it/cnr/isti/workflow/manager/containers/types/ContainerType.java index d0954a8..685866b 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/containers/types/ContainerType.java +++ b/src/main/java/it/cnr/isti/workflow/manager/containers/types/ContainerType.java @@ -2,6 +2,7 @@ package it.cnr.isti.workflow.manager.containers.types; import com.fasterxml.jackson.annotation.JsonIgnore; import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; public interface ContainerType { @@ -13,6 +14,10 @@ public interface ContainerType { boolean isUserInteractive(); + default NodeTypeCapabilities getCapabilities() { + return NodeTypeCapabilities.container(); + } + @JsonIgnore Class> getContainerConfigurationClass(); } diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java index 86b3108..d52a9f6 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java @@ -11,6 +11,7 @@ import it.cnr.isti.workflow.manager.blocks.configurations.JsonSchemaProducer; import it.cnr.isti.workflow.manager.blocks.factories.BlockFactory; import it.cnr.isti.workflow.manager.blocks.types.BlockType; import it.cnr.isti.workflow.manager.executions.design.FlowSharedVariableCatalogService; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; import java.util.List; import java.util.Map; @@ -55,6 +56,7 @@ public class BlocksController { String type, String description, boolean userInteractive, + NodeTypeCapabilities capabilities, InteractionContractDescriptor interactionContract, boolean hasExampleBlock, String exampleBlockEndpoint, @@ -143,6 +145,7 @@ public class BlocksController { blockType.getName(), blockType.getDescription(), blockType.isUserInteractive(), + blockType.getCapabilities(), resolveInteractionContract(blockType), true, getExampleEndpoint(blockType), @@ -156,6 +159,7 @@ public class BlocksController { blockType.getName(), blockType.getDescription(), blockType.isUserInteractive(), + blockType.getCapabilities(), resolveInteractionContract(blockType), true, getExampleEndpoint(blockType), @@ -174,6 +178,7 @@ public class BlocksController { descriptor.type(), descriptor.description(), descriptor.userInteractive(), + descriptor.capabilities(), descriptor.interactionContract(), descriptor.hasExampleBlock(), descriptor.exampleBlockEndpoint(), diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/ContainersController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/ContainersController.java index 6d803aa..ec91698 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/ContainersController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/ContainersController.java @@ -25,6 +25,7 @@ import it.cnr.isti.workflow.manager.containers.types.ContainerType; import it.cnr.isti.workflow.manager.blocks.configurations.JsonSchemaProducer; import it.cnr.isti.workflow.manager.blocks.configurations.JsonSchemaCatalogBundler; import it.cnr.isti.workflow.manager.flows.model.FlowData; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; import it.cnr.isti.workflow.manager.flows.validation.ValidationError; import it.cnr.isti.workflow.manager.ios.IODescriptor; import tools.jackson.databind.JsonNode; @@ -60,6 +61,7 @@ public class ContainersController { String type, String description, boolean userInteractive, + NodeTypeCapabilities capabilities, boolean hasExampleContainer, String exampleContainerEndpoint, String configurationType, @@ -194,6 +196,7 @@ public class ContainersController { containerType.getName(), containerType.getDescription(), containerType.isUserInteractive(), + containerType.getCapabilities(), true, "/containers/types/" + containerType.getName() + "/example", configurationClass == null ? null : configurationClass.getSimpleName(), @@ -206,6 +209,7 @@ public class ContainersController { descriptor.type(), descriptor.description(), descriptor.userInteractive(), + descriptor.capabilities(), descriptor.hasExampleContainer(), descriptor.exampleContainerEndpoint(), descriptor.configurationType(), diff --git a/src/main/java/it/cnr/isti/workflow/manager/flows/model/capabilities/NodeTypeCapabilities.java b/src/main/java/it/cnr/isti/workflow/manager/flows/model/capabilities/NodeTypeCapabilities.java new file mode 100644 index 0000000..8b0f537 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/flows/model/capabilities/NodeTypeCapabilities.java @@ -0,0 +1,35 @@ +package it.cnr.isti.workflow.manager.flows.model.capabilities; + +public record NodeTypeCapabilities( + NodeVisualRole visualRole, + boolean terminal, + boolean biasAnnotationsAllowed, + boolean allowsIncomingConnections, + boolean allowsOutgoingConnections, + boolean canDependOnOtherNodes, + boolean canHaveDependentNodes) { + + public NodeTypeCapabilities { + visualRole = visualRole == null ? NodeVisualRole.ACTIVITY : visualRole; + } + + public static NodeTypeCapabilities activity() { + return new NodeTypeCapabilities(NodeVisualRole.ACTIVITY, false, true, true, true, true, true); + } + + public static NodeTypeCapabilities decision() { + return new NodeTypeCapabilities(NodeVisualRole.DECISION, false, true, true, true, true, true); + } + + public static NodeTypeCapabilities merge() { + return new NodeTypeCapabilities(NodeVisualRole.MERGE, false, true, true, true, true, true); + } + + public static NodeTypeCapabilities end() { + return new NodeTypeCapabilities(NodeVisualRole.END, true, false, true, false, false, false); + } + + public static NodeTypeCapabilities container() { + return new NodeTypeCapabilities(NodeVisualRole.CONTAINER, false, true, true, true, true, true); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/flows/model/capabilities/NodeVisualRole.java b/src/main/java/it/cnr/isti/workflow/manager/flows/model/capabilities/NodeVisualRole.java new file mode 100644 index 0000000..016f01f --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/flows/model/capabilities/NodeVisualRole.java @@ -0,0 +1,9 @@ +package it.cnr.isti.workflow.manager.flows.model.capabilities; + +public enum NodeVisualRole { + ACTIVITY, + DECISION, + MERGE, + END, + CONTAINER +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowDataValidator.java b/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowDataValidator.java index 5483c0c..9890ed5 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowDataValidator.java +++ b/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowDataValidator.java @@ -36,6 +36,7 @@ import it.cnr.isti.workflow.manager.flows.model.FlowLane; import it.cnr.isti.workflow.manager.flows.model.FlowNode; import it.cnr.isti.workflow.manager.flows.model.bias.BlockBiasAnnotation; import it.cnr.isti.workflow.manager.flows.model.bias.BiasActivationMode; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeTypeCapabilities; import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasBehaviorAdapterRegistry; import it.cnr.isti.workflow.manager.ios.IODescriptor; import jakarta.validation.ConstraintValidator; @@ -187,6 +188,10 @@ public class FlowDataValidator implements ConstraintValidator annotations = node.getBiasAnnotations(); + if (!nodeTypeCapabilities(node).biasAnnotationsAllowed() && !annotations.isEmpty()) { + throw validationError(error(ValidationErrorCode.BIAS_ANNOTATIONS_NOT_ALLOWED, entity, node.getId(), + "biasAnnotations", "Bias annotations are not allowed for node type " + node.getClass().getSimpleName())); + } if (annotations.size() > BlockBiasAnnotation.MAX_PER_NODE) { throw validationError(error(ValidationErrorCode.TOO_MANY_BIAS_ANNOTATIONS, entity, node.getId(), "biasAnnotations", "A node cannot contain more than " + BlockBiasAnnotation.MAX_PER_NODE + " bias annotations")); @@ -369,6 +374,16 @@ public class FlowDataValidator implements ConstraintValidator ? "block" : "container"; } + private NodeTypeCapabilities nodeTypeCapabilities(FlowNode node) { + if (node instanceof Block block) { + return block.getType().getCapabilities(); + } + if (node instanceof Container container) { + return container.getType().getCapabilities(); + } + return NodeTypeCapabilities.activity(); + } + private void validateContainerSubFlow(Container container, ContainerConfiguration containerConfiguration, String fieldName, FlowData subFlow) { String fieldPath = "specificConfiguration." + fieldName; @@ -422,6 +437,18 @@ public class FlowDataValidator implements ConstraintValidator flows = ObjectMapperHolder.mapper.readValue( bundledFlows.toFile(), ObjectMapperHolder.mapper.getTypeFactory().constructCollectionType(List.class, ImportedFlow.class)); - ImportedFlow fullRevised = flows.stream() - .filter(flow -> "Jensen Recruitment Process - Full Revised".equals(flow.name())) - .findFirst() - .orElseThrow(); - ImportedFlow mitigated = flows.stream() - .filter(flow -> "Jensen Recruitment Process - Mitigated".equals(flow.name())) + assertFalse(flows.stream().anyMatch(flow -> "Jensen Recruitment Process - Full Revised".equals(flow.name()))); + assertFalse(flows.stream().anyMatch(flow -> "Jensen Recruitment Process - Mitigated".equals(flow.name()))); + + ImportedFlow testBiased = flows.stream() + .filter(flow -> "test biased".equals(flow.name())) .findFirst() .orElseThrow(); - for (ImportedFlow flow : List.of(fullRevised, mitigated)) { - Set> importErrors = validator.validate(flow); - Set> structureErrors = validator.validate(flow.flow()); - assertTrue(importErrors.isEmpty(), () -> "Import errors for " + flow.name() + ": " + importErrors); - assertTrue(structureErrors.isEmpty(), () -> "Structure errors for " + flow.name() + ": " + structureErrors); - assertTrue(flowExecutionValidator.isExecutable(flow.flow()), - () -> "Execution errors for " + flow.name() + ": " + flowExecutionValidator.collectErrors(flow.flow())); - } + Set> importErrors = validator.validate(testBiased); + Set> structureErrors = validator.validate(testBiased.flow()); + assertTrue(importErrors.isEmpty(), () -> "Import errors for test biased: " + importErrors); + assertTrue(structureErrors.isEmpty(), () -> "Structure errors for test biased: " + structureErrors); + assertTrue(flowExecutionValidator.isExecutable(testBiased.flow()), + () -> "Execution errors for test biased: " + flowExecutionValidator.collectErrors(testBiased.flow())); - assertEquals(2, fullRevised.flow().getContainers().size()); - assertEquals(3, mitigated.flow().getContainers().size()); - assertFalse(mitigated.flow().getContainers().stream() - .flatMap(container -> container.getBiasAnnotations().stream()) - .toList() - .isEmpty()); - - Set analysisIds = mitigated.flow().getNodes().stream() + assertEquals(6, testBiased.flow().getBlocks().size()); + assertEquals(3, testBiased.flow().getNodes().stream() .flatMap(node -> node.getBiasAnnotations().stream()) - .map(BlockBiasAnnotation::analysisId) - .filter(id -> id != null && !id.isBlank()) - .collect(Collectors.toSet()); - Set expectedAnalysisIds = java.util.stream.IntStream.rangeClosed(1, 16) - .mapToObj(index -> "FH-J" + index) - .collect(Collectors.toSet()); - assertEquals(expectedAnalysisIds, analysisIds); + .count()); + assertEquals(2, testBiased.flow().getNodes().stream() + .flatMap(node -> node.getBiasAnnotations().stream()) + .map(BlockBiasAnnotation::behavioralProbe) + .filter(java.util.Objects::nonNull) + .count()); + assertEquals(1, testBiased.flow().getBlocks().stream() + .filter(block -> block.getOutputs().size() == 2) + .count()); } @Test @@ -112,7 +104,7 @@ public class FlowImportComponentTest { List jensenFlows = flows.stream() .filter(flow -> flow.name().startsWith("Jensen Recruitment Process - ")) .toList(); - assertEquals(4, jensenFlows.size()); + assertEquals(2, jensenFlows.size()); for (ImportedFlow flow : jensenFlows) { var firstBlock = flow.flow().getBlocks().stream() diff --git a/src/test/java/it/cnr/isti/workflow/manager/flows/validation/NodeTypeCapabilitiesIntegrationTest.java b/src/test/java/it/cnr/isti/workflow/manager/flows/validation/NodeTypeCapabilitiesIntegrationTest.java new file mode 100644 index 0000000..4ac648f --- /dev/null +++ b/src/test/java/it/cnr/isti/workflow/manager/flows/validation/NodeTypeCapabilitiesIntegrationTest.java @@ -0,0 +1,197 @@ +package it.cnr.isti.workflow.manager.flows.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import it.cnr.isti.workflow.manager.blocks.Block; +import it.cnr.isti.workflow.manager.blocks.configurations.EndBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.EndMode; +import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.factories.EndBlockFactory; +import it.cnr.isti.workflow.manager.blocks.factories.HumanInteractiveBlockFactory; +import it.cnr.isti.workflow.manager.blocks.types.EndBlockType; +import it.cnr.isti.workflow.manager.blocks.types.ExclusiveMergeBlockType; +import it.cnr.isti.workflow.manager.blocks.types.HumanDecisionBlockType; +import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType; +import it.cnr.isti.workflow.manager.containers.types.GenericContainerType; +import it.cnr.isti.workflow.manager.controllers.BlocksController; +import it.cnr.isti.workflow.manager.controllers.ContainersController; +import it.cnr.isti.workflow.manager.flows.model.Connection; +import it.cnr.isti.workflow.manager.flows.model.Dependency; +import it.cnr.isti.workflow.manager.flows.model.FlowData; +import it.cnr.isti.workflow.manager.flows.model.bias.BiasCategory; +import it.cnr.isti.workflow.manager.flows.model.bias.BiasSeverity; +import it.cnr.isti.workflow.manager.flows.model.bias.BlockBiasAnnotation; +import it.cnr.isti.workflow.manager.flows.model.capabilities.NodeVisualRole; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; + +@SpringBootTest +@TestPropertySource(locations = "classpath:test.properties") +class NodeTypeCapabilitiesIntegrationTest { + + @Autowired + BlocksController blocksController; + + @Autowired + ContainersController containersController; + + @Autowired + EndBlockFactory endBlockFactory; + + @Autowired + HumanInteractiveBlockFactory humanInteractiveBlockFactory; + + @Autowired + Validator validator; + + @Test + void catalogsExposeVisualAndStructuralCapabilities() { + var end = blocksController.getConfigurationDescriptorForType(EndBlockType.TYPE).capabilities(); + assertEquals(NodeVisualRole.END, end.visualRole()); + assertTrue(end.terminal()); + assertFalse(end.biasAnnotationsAllowed()); + assertTrue(end.allowsIncomingConnections()); + assertFalse(end.allowsOutgoingConnections()); + assertFalse(end.canDependOnOtherNodes()); + assertFalse(end.canHaveDependentNodes()); + + var decision = blocksController.getConfigurationDescriptorForType(HumanDecisionBlockType.TYPE).capabilities(); + assertEquals(NodeVisualRole.DECISION, decision.visualRole()); + assertTrue(decision.biasAnnotationsAllowed()); + + var merge = blocksController.getConfigurationDescriptorForType(ExclusiveMergeBlockType.TYPE).capabilities(); + assertEquals(NodeVisualRole.MERGE, merge.visualRole()); + + var container = containersController.getTypes().stream() + .filter(descriptor -> GenericContainerType.TYPE.equals(descriptor.type())) + .findFirst().orElseThrow().capabilities(); + assertEquals(NodeVisualRole.CONTAINER, container.visualRole()); + + var compactEnd = blocksController.getTypeCatalog().descriptors().stream() + .filter(descriptor -> EndBlockType.TYPE.equals(descriptor.type())) + .findFirst().orElseThrow().capabilities(); + assertEquals(end, compactEnd); + + var compactContainer = containersController.getTypeCatalog().descriptors().stream() + .filter(descriptor -> GenericContainerType.TYPE.equals(descriptor.type())) + .findFirst().orElseThrow().capabilities(); + assertEquals(container, compactContainer); + } + + @Test + void endBlockRejectsBiasAnnotations() { + Block original = end(); + Block annotated = Block.builder() + .specificConfiguration(original.getSpecificConfiguration()) + .inputs(original.getInputs()) + .outputs(original.getOutputs()) + .type(original.getType()) + .biasAnnotation(new BlockBiasAnnotation( + "end-risk", BiasCategory.TRANSPARENCY_RISK, BiasSeverity.LOW, + "Terminal marker risk", null, null, null, null, null, null)) + .build(); + + assertEquals(List.of(ValidationErrorCode.BIAS_ANNOTATIONS_NOT_ALLOWED), + errorCodes(FlowData.builder().block(annotated).build())); + } + + @Test + void endBlockCannotDependOnAnotherNode() { + Block activity = activity(); + Block end = end(); + FlowData flow = FlowData.builder() + .block(activity) + .block(end) + .dependency(Dependency.builder().sourceId(activity.getId()).targetId(end.getId()).build()) + .build(); + + assertEquals(List.of(ValidationErrorCode.NODE_CANNOT_DEPEND_ON_OTHER_NODES), errorCodes(flow)); + } + + @Test + void endBlockCannotHaveDependentNodes() { + Block end = end(); + Block activity = activity(); + FlowData flow = FlowData.builder() + .block(end) + .block(activity) + .dependency(Dependency.builder().sourceId(end.getId()).targetId(activity.getId()).build()) + .build(); + + assertEquals(List.of(ValidationErrorCode.NODE_CANNOT_HAVE_DEPENDENT_NODES), errorCodes(flow)); + } + + @Test + void endBlockStillAcceptsOneIncomingFlowConnection() { + Block activity = activity(); + Block end = end(); + FlowData flow = FlowData.builder() + .block(activity) + .block(end) + .connection(Connection.builder() + .sourceId(activity.getId()) + .sourceName("output") + .targetId(end.getId()) + .targetName(EndBlockFactory.INPUT_NAME) + .build()) + .build(); + + assertTrue(validate(flow).isEmpty(), () -> "Unexpected validation errors: " + validate(flow)); + } + + @Test + void endBlockRejectsOutgoingFlowConnections() { + Block end = end(); + Block activity = activity(); + FlowData flow = FlowData.builder() + .block(end) + .block(activity) + .connection(Connection.builder() + .sourceId(end.getId()) + .sourceName("output") + .targetId(activity.getId()) + .targetName("input") + .build()) + .build(); + + assertEquals(List.of(ValidationErrorCode.NODE_OUTGOING_CONNECTION_NOT_ALLOWED), errorCodes(flow)); + } + + private Block end() { + return endBlockFactory.create(EndBlockConfiguration.builder() + .name("Completed") + .outcomeCode("COMPLETED") + .outcomeLabel("Completed") + .mode(EndMode.PATH_END) + .build()); + } + + private Block activity() { + return humanInteractiveBlockFactory.create(HumanInteractiveBlockConfiguration.builder() + .name("Review") + .actionDescription("Review the dossier") + .build()); + } + + private List validate(FlowData flow) { + Set> violations = validator.validate(flow); + if (violations.isEmpty()) { + return List.of(); + } + return ValidationErrorCodec.decode(violations.iterator().next().getMessage()); + } + + private List errorCodes(FlowData flow) { + return validate(flow).stream().map(ValidationError::code).toList(); + } +}