feat: evaluate built-in routing predicates and parameterized host conditions
Store transition when as JSON instead of a lookup key so designers can compose closed-set logic while hosts still supply complex refs with args. Match conditional edges before the unconditional default. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,16 +1,42 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Host-registered keys for designers and REST dropdowns; not used by the engine at runtime. */
|
||||
public interface OrdoCatalog {
|
||||
Set<String> PARAM_TYPES = Set.of("number", "string", "boolean");
|
||||
|
||||
List<Item> conditions();
|
||||
|
||||
List<Item> actions();
|
||||
|
||||
List<Item> assignees();
|
||||
|
||||
record Item(String key, String label) {
|
||||
record Item(String key, String label, List<Param> params) {
|
||||
public Item {
|
||||
Objects.requireNonNull(key, "key must not be null");
|
||||
Objects.requireNonNull(label, "label must not be null");
|
||||
params = params == null ? List.of() : List.copyOf(params);
|
||||
}
|
||||
|
||||
public Item(String key, String label) {
|
||||
this(key, label, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
record Param(String name, String type, String label) {
|
||||
public Param {
|
||||
ApprovalStep.requireText(name, "param name");
|
||||
ApprovalStep.requireText(type, "param type");
|
||||
ApprovalStep.requireText(label, "param label");
|
||||
type = type.strip().toLowerCase(Locale.ROOT);
|
||||
if (!PARAM_TYPES.contains(type)) {
|
||||
throw new IllegalArgumentException("param type must be number, string or boolean");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static OrdoCatalog empty() {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.jetlumen.ordo.api;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -72,7 +73,8 @@ public final class ProcessDefinitionParser {
|
||||
throw new IllegalArgumentException("transition must not be null");
|
||||
}
|
||||
int priority = transition.priority() == null ? 0 : transition.priority();
|
||||
transitions.add(new StepTransition(transition.from(), transition.to(), transition.when(), priority));
|
||||
transitions.add(new StepTransition(transition.from(), transition.to(),
|
||||
RoutingWhen.parse(transition.when()), priority));
|
||||
}
|
||||
}
|
||||
return new ProcessDefinition(document.id(), document.name(), steps, transitions);
|
||||
@@ -93,7 +95,7 @@ public final class ProcessDefinitionParser {
|
||||
List<TransitionDocument> transitions = new ArrayList<>(definition.transitions().size());
|
||||
for (StepTransition transition : definition.transitions()) {
|
||||
transitions.add(new TransitionDocument(transition.fromStepId(), transition.toStepId(),
|
||||
transition.conditionKey(), transition.priority()));
|
||||
transition.when() == null ? null : transition.when().toTree(), transition.priority()));
|
||||
}
|
||||
return new DefinitionDocument(definition.id(), definition.name(), definition.version(),
|
||||
definition.steps().get(0).id(), steps, transitions);
|
||||
@@ -171,7 +173,7 @@ public final class ProcessDefinitionParser {
|
||||
private record TransitionDocument(
|
||||
@JsonProperty("from") String from,
|
||||
@JsonProperty("to") String to,
|
||||
@JsonProperty("when") String when,
|
||||
@JsonProperty("when") JsonNode when,
|
||||
Integer priority) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Evaluates a named routing condition against the running instance's context. Hosts supply a
|
||||
* singleton implementation (same pattern as {@link AssigneeResolver}); the database only stores
|
||||
* the {@code conditionKey} string.
|
||||
* Evaluates a named routing condition ({@code when.ref}) against the running instance's context.
|
||||
* Hosts supply a singleton implementation (same pattern as {@link AssigneeResolver}); the
|
||||
* definition stores the {@code ref} key and optional {@code args}.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface RoutingCondition {
|
||||
boolean matches(String conditionKey, ProcessContext context);
|
||||
boolean matches(String conditionKey, Map<String, Object> args, ProcessContext context);
|
||||
|
||||
static RoutingCondition always() {
|
||||
return (key, context) -> true;
|
||||
return (key, args, context) -> true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Closed-set boolean tree evaluated against {@link ProcessContext} variables. */
|
||||
public sealed interface RoutingPredicate permits RoutingPredicate.Compare, RoutingPredicate.In,
|
||||
RoutingPredicate.And, RoutingPredicate.Or, RoutingPredicate.Not {
|
||||
|
||||
int MAX_DEPTH = 8;
|
||||
int MAX_LEAVES = 32;
|
||||
|
||||
enum Op {
|
||||
EQ, NE, GT, GTE, LT, LTE
|
||||
}
|
||||
|
||||
record Compare(Op op, String var, Object value) implements RoutingPredicate {
|
||||
public Compare {
|
||||
Objects.requireNonNull(op, "op must not be null");
|
||||
ApprovalStep.requireText(var, "var");
|
||||
value = requireLiteral(value, "value");
|
||||
}
|
||||
}
|
||||
|
||||
record In(String var, List<Object> values) implements RoutingPredicate {
|
||||
public In {
|
||||
ApprovalStep.requireText(var, "var");
|
||||
Objects.requireNonNull(values, "values must not be null");
|
||||
if (values.isEmpty()) {
|
||||
throw new IllegalArgumentException("in values must not be empty");
|
||||
}
|
||||
List<Object> copied = new ArrayList<>(values.size());
|
||||
for (Object value : values) {
|
||||
copied.add(requireLiteral(value, "in value"));
|
||||
}
|
||||
values = List.copyOf(copied);
|
||||
}
|
||||
}
|
||||
|
||||
record And(List<RoutingPredicate> args) implements RoutingPredicate {
|
||||
public And {
|
||||
args = copyPredicates(args, "and");
|
||||
}
|
||||
}
|
||||
|
||||
record Or(List<RoutingPredicate> args) implements RoutingPredicate {
|
||||
public Or {
|
||||
args = copyPredicates(args, "or");
|
||||
}
|
||||
}
|
||||
|
||||
record Not(RoutingPredicate arg) implements RoutingPredicate {
|
||||
public Not {
|
||||
Objects.requireNonNull(arg, "not arg must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
static Compare eq(String var, Object value) {
|
||||
return new Compare(Op.EQ, var, value);
|
||||
}
|
||||
|
||||
static Compare ne(String var, Object value) {
|
||||
return new Compare(Op.NE, var, value);
|
||||
}
|
||||
|
||||
static Compare gt(String var, Object value) {
|
||||
return new Compare(Op.GT, var, value);
|
||||
}
|
||||
|
||||
static Compare gte(String var, Object value) {
|
||||
return new Compare(Op.GTE, var, value);
|
||||
}
|
||||
|
||||
static Compare lt(String var, Object value) {
|
||||
return new Compare(Op.LT, var, value);
|
||||
}
|
||||
|
||||
static Compare lte(String var, Object value) {
|
||||
return new Compare(Op.LTE, var, value);
|
||||
}
|
||||
|
||||
static In in(String var, List<Object> values) {
|
||||
return new In(var, values);
|
||||
}
|
||||
|
||||
static And and(RoutingPredicate... args) {
|
||||
return new And(List.of(args));
|
||||
}
|
||||
|
||||
static Or or(RoutingPredicate... args) {
|
||||
return new Or(List.of(args));
|
||||
}
|
||||
|
||||
static Not not(RoutingPredicate arg) {
|
||||
return new Not(arg);
|
||||
}
|
||||
|
||||
static RoutingPredicate parse(JsonNode node) {
|
||||
int[] leaves = {0};
|
||||
return parse(node, 0, leaves);
|
||||
}
|
||||
|
||||
static RoutingPredicate parse(JsonNode node, int depth, int[] leaves) {
|
||||
if (node == null || !node.isObject()) {
|
||||
throw new IllegalArgumentException("predicate must be a JSON object");
|
||||
}
|
||||
if (depth > MAX_DEPTH) {
|
||||
throw new IllegalArgumentException("predicate depth must not exceed " + MAX_DEPTH);
|
||||
}
|
||||
if (node.size() != 1) {
|
||||
throw new IllegalArgumentException("predicate must contain exactly one operator");
|
||||
}
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
String op = field.getKey();
|
||||
JsonNode value = field.getValue();
|
||||
return switch (op) {
|
||||
case "eq" -> compare(Op.EQ, value, leaves);
|
||||
case "ne" -> compare(Op.NE, value, leaves);
|
||||
case "gt" -> compare(Op.GT, value, leaves);
|
||||
case "gte" -> compare(Op.GTE, value, leaves);
|
||||
case "lt" -> compare(Op.LT, value, leaves);
|
||||
case "lte" -> compare(Op.LTE, value, leaves);
|
||||
case "in" -> inOp(value, leaves);
|
||||
case "and" -> new And(parseList(value, depth, leaves));
|
||||
case "or" -> new Or(parseList(value, depth, leaves));
|
||||
case "not" -> new Not(parse(value, depth + 1, leaves));
|
||||
default -> throw new IllegalArgumentException("unknown predicate operator: " + op);
|
||||
};
|
||||
}
|
||||
|
||||
default JsonNode toTree() {
|
||||
return toTree(JsonNodeFactory.instance);
|
||||
}
|
||||
|
||||
default JsonNode toTree(JsonNodeFactory factory) {
|
||||
ObjectNode object = factory.objectNode();
|
||||
if (this instanceof Compare compare) {
|
||||
ArrayNode pair = object.putArray(compare.op().name().toLowerCase(Locale.ROOT));
|
||||
pair.add(compare.var());
|
||||
addLiteral(pair, compare.value());
|
||||
return object;
|
||||
}
|
||||
if (this instanceof In in) {
|
||||
ArrayNode pair = object.putArray("in");
|
||||
pair.add(in.var());
|
||||
ArrayNode values = pair.addArray();
|
||||
for (Object value : in.values()) {
|
||||
addLiteral(values, value);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
if (this instanceof And and) {
|
||||
ArrayNode args = object.putArray("and");
|
||||
for (RoutingPredicate arg : and.args()) {
|
||||
args.add(arg.toTree(factory));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
if (this instanceof Or or) {
|
||||
ArrayNode args = object.putArray("or");
|
||||
for (RoutingPredicate arg : or.args()) {
|
||||
args.add(arg.toTree(factory));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
Not not = (Not) this;
|
||||
object.set("not", not.arg().toTree(factory));
|
||||
return object;
|
||||
}
|
||||
|
||||
private static Compare compare(Op op, JsonNode value, int[] leaves) {
|
||||
bumpLeaf(leaves);
|
||||
if (value == null || !value.isArray() || value.size() != 2) {
|
||||
throw new IllegalArgumentException(op.name().toLowerCase(Locale.ROOT) + " must be [var, literal]");
|
||||
}
|
||||
return new Compare(op, requireVar(value.get(0)), literal(value.get(1)));
|
||||
}
|
||||
|
||||
private static In inOp(JsonNode value, int[] leaves) {
|
||||
bumpLeaf(leaves);
|
||||
if (value == null || !value.isArray() || value.size() != 2 || !value.get(1).isArray()) {
|
||||
throw new IllegalArgumentException("in must be [var, [literals...]]");
|
||||
}
|
||||
List<Object> values = new ArrayList<>();
|
||||
for (JsonNode item : value.get(1)) {
|
||||
values.add(literal(item));
|
||||
}
|
||||
return new In(requireVar(value.get(0)), values);
|
||||
}
|
||||
|
||||
private static List<RoutingPredicate> parseList(JsonNode value, int depth, int[] leaves) {
|
||||
if (value == null || !value.isArray() || value.isEmpty()) {
|
||||
throw new IllegalArgumentException("logical operator requires a non-empty array");
|
||||
}
|
||||
List<RoutingPredicate> args = new ArrayList<>(value.size());
|
||||
for (JsonNode child : value) {
|
||||
args.add(parse(child, depth + 1, leaves));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
private static void bumpLeaf(int[] leaves) {
|
||||
leaves[0]++;
|
||||
if (leaves[0] > MAX_LEAVES) {
|
||||
throw new IllegalArgumentException("predicate leaves must not exceed " + MAX_LEAVES);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireVar(JsonNode node) {
|
||||
if (node == null || !node.isTextual()) {
|
||||
throw new IllegalArgumentException("variable path must be a string");
|
||||
}
|
||||
return node.textValue();
|
||||
}
|
||||
|
||||
private static Object literal(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) {
|
||||
throw new IllegalArgumentException("literal must not be null");
|
||||
}
|
||||
if (node.isBoolean()) {
|
||||
return node.booleanValue();
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return node.textValue();
|
||||
}
|
||||
if (node.isNumber()) {
|
||||
return node.numberValue();
|
||||
}
|
||||
throw new IllegalArgumentException("literal must be string, number or boolean");
|
||||
}
|
||||
|
||||
private static Object requireLiteral(Object value, String name) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException(name + " must not be null");
|
||||
}
|
||||
if (value instanceof String || value instanceof Number || value instanceof Boolean) {
|
||||
return value;
|
||||
}
|
||||
throw new IllegalArgumentException(name + " must be string, number or boolean");
|
||||
}
|
||||
|
||||
private static List<RoutingPredicate> copyPredicates(List<RoutingPredicate> args, String name) {
|
||||
Objects.requireNonNull(args, name + " args must not be null");
|
||||
if (args.isEmpty()) {
|
||||
throw new IllegalArgumentException(name + " args must not be empty");
|
||||
}
|
||||
for (RoutingPredicate arg : args) {
|
||||
Objects.requireNonNull(arg, name + " arg must not be null");
|
||||
}
|
||||
return List.copyOf(args);
|
||||
}
|
||||
|
||||
private static void addLiteral(ArrayNode array, Object value) {
|
||||
if (value instanceof String text) {
|
||||
array.add(text);
|
||||
} else if (value instanceof Boolean bool) {
|
||||
array.add(bool);
|
||||
} else if (value instanceof Integer number) {
|
||||
array.add(number);
|
||||
} else if (value instanceof Long number) {
|
||||
array.add(number);
|
||||
} else if (value instanceof Double number) {
|
||||
array.add(number);
|
||||
} else if (value instanceof Float number) {
|
||||
array.add(number);
|
||||
} else if (value instanceof Number number) {
|
||||
array.add(number.doubleValue());
|
||||
} else {
|
||||
throw new IllegalArgumentException("unsupported literal: " + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Outgoing-edge condition: either a built-in {@link RoutingPredicate} or a host
|
||||
* {@link RoutingCondition} reference with arguments.
|
||||
*/
|
||||
public sealed interface RoutingWhen permits RoutingWhen.Predicate, RoutingWhen.Ref {
|
||||
ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
record Predicate(RoutingPredicate tree) implements RoutingWhen {
|
||||
public Predicate {
|
||||
Objects.requireNonNull(tree, "predicate must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
record Ref(String key, Map<String, Object> args) implements RoutingWhen {
|
||||
public Ref {
|
||||
ApprovalStep.requireText(key, "ref");
|
||||
args = Map.copyOf(args == null ? Map.of() : args);
|
||||
}
|
||||
}
|
||||
|
||||
static Predicate predicate(RoutingPredicate tree) {
|
||||
return new Predicate(tree);
|
||||
}
|
||||
|
||||
static Ref ref(String key) {
|
||||
return new Ref(key, Map.of());
|
||||
}
|
||||
|
||||
static Ref ref(String key, Map<String, Object> args) {
|
||||
return new Ref(key, args);
|
||||
}
|
||||
|
||||
static RoutingWhen parse(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isTextual() && node.textValue().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (!node.isObject()) {
|
||||
throw new IllegalArgumentException("when must be a JSON object");
|
||||
}
|
||||
if (node.has("ref")) {
|
||||
if (node.size() > 2 || (node.size() == 2 && !node.has("args"))) {
|
||||
throw new IllegalArgumentException("ref when may only contain ref and optional args");
|
||||
}
|
||||
JsonNode refNode = node.get("ref");
|
||||
if (refNode == null || !refNode.isTextual()) {
|
||||
throw new IllegalArgumentException("ref must be a string");
|
||||
}
|
||||
Map<String, Object> args = Map.of();
|
||||
if (node.has("args")) {
|
||||
JsonNode argsNode = node.get("args");
|
||||
if (argsNode == null || argsNode.isNull()) {
|
||||
args = Map.of();
|
||||
} else if (!argsNode.isObject()) {
|
||||
throw new IllegalArgumentException("args must be a JSON object");
|
||||
} else {
|
||||
args = MAPPER.convertValue(argsNode, new TypeReference<>() {
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Ref(refNode.textValue(), args);
|
||||
}
|
||||
return new Predicate(RoutingPredicate.parse(node));
|
||||
}
|
||||
|
||||
static RoutingWhen parseJson(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parse(MAPPER.readTree(json));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("invalid when json", e);
|
||||
}
|
||||
}
|
||||
|
||||
default JsonNode toTree() {
|
||||
if (this instanceof Predicate predicate) {
|
||||
return predicate.tree().toTree();
|
||||
}
|
||||
Ref ref = (Ref) this;
|
||||
ObjectNode object = JsonNodeFactory.instance.objectNode();
|
||||
object.put("ref", ref.key());
|
||||
if (!ref.args().isEmpty()) {
|
||||
object.set("args", MAPPER.valueToTree(ref.args()));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
default String toJson() {
|
||||
try {
|
||||
return MAPPER.writeValueAsString(toTree());
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("failed to serialize when", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ package com.jetlumen.ordo.api;
|
||||
|
||||
/**
|
||||
* An outgoing edge from an approval step. When {@link #toStepId()} is {@code null}, matching this
|
||||
* transition terminates the instance as approved. A blank {@link #conditionKey()} is unconditional
|
||||
* transition terminates the instance as approved. A null {@link #when()} is unconditional
|
||||
* (always matches) and is typically used as the else/default branch.
|
||||
*/
|
||||
public record StepTransition(String fromStepId, String toStepId, String conditionKey, int priority) {
|
||||
public record StepTransition(String fromStepId, String toStepId, RoutingWhen when, int priority) {
|
||||
public StepTransition {
|
||||
ApprovalStep.requireText(fromStepId, "fromStepId");
|
||||
if (toStepId != null && toStepId.isBlank()) {
|
||||
@@ -17,15 +17,32 @@ public record StepTransition(String fromStepId, String toStepId, String conditio
|
||||
return new StepTransition(fromStepId, toStepId, null, 0);
|
||||
}
|
||||
|
||||
public static StepTransition when(String fromStepId, String toStepId, String conditionKey, int priority) {
|
||||
return new StepTransition(fromStepId, toStepId, conditionKey, priority);
|
||||
public static StepTransition when(String fromStepId, String toStepId, RoutingWhen when, int priority) {
|
||||
return new StepTransition(fromStepId, toStepId, when, priority);
|
||||
}
|
||||
|
||||
public static StepTransition when(String fromStepId, String toStepId, RoutingPredicate predicate, int priority) {
|
||||
return new StepTransition(fromStepId, toStepId, RoutingWhen.predicate(predicate), priority);
|
||||
}
|
||||
|
||||
public static StepTransition ref(String fromStepId, String toStepId, String key, java.util.Map<String, Object> args,
|
||||
int priority) {
|
||||
return new StepTransition(fromStepId, toStepId, RoutingWhen.ref(key, args), priority);
|
||||
}
|
||||
|
||||
public static StepTransition end(String fromStepId) {
|
||||
return new StepTransition(fromStepId, null, null, 0);
|
||||
}
|
||||
|
||||
public static StepTransition endWhen(String fromStepId, String conditionKey, int priority) {
|
||||
return new StepTransition(fromStepId, null, conditionKey, priority);
|
||||
public static StepTransition endWhen(String fromStepId, RoutingWhen when, int priority) {
|
||||
return new StepTransition(fromStepId, null, when, priority);
|
||||
}
|
||||
|
||||
public static StepTransition endWhen(String fromStepId, RoutingPredicate predicate, int priority) {
|
||||
return new StepTransition(fromStepId, null, RoutingWhen.predicate(predicate), priority);
|
||||
}
|
||||
|
||||
public static StepTransition endRef(String fromStepId, String key, java.util.Map<String, Object> args, int priority) {
|
||||
return new StepTransition(fromStepId, null, RoutingWhen.ref(key, args), priority);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class ProcessDefinitionParserTest {
|
||||
{ "id": "hr", "name": "HR approval", "candidates": ["henry"], "policy": "ANY" }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 },
|
||||
{ "from": "manager", "to": "hr", "when": { "gt": ["days", 3] }, "priority": 0 },
|
||||
{ "from": "manager", "to": null, "priority": 1 },
|
||||
{ "from": "hr", "to": null }
|
||||
]
|
||||
@@ -33,7 +33,7 @@ class ProcessDefinitionParserTest {
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("hr", "HR approval", "henry")
|
||||
), List.of(
|
||||
StepTransition.when("manager", "hr", "days-gt-3", 0),
|
||||
StepTransition.when("manager", "hr", RoutingPredicate.gt("days", 3), 0),
|
||||
new StepTransition("manager", null, null, 1),
|
||||
StepTransition.end("hr")
|
||||
));
|
||||
@@ -76,7 +76,7 @@ class ProcessDefinitionParserTest {
|
||||
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 },
|
||||
{ "from": "manager", "to": "hr", "when": { "gt": ["days", 3] }, "priority": 0 },
|
||||
{ "from": "manager", "to": null, "priority": 1 },
|
||||
{ "from": "hr", "to": null }
|
||||
]
|
||||
@@ -165,8 +165,8 @@ class ProcessDefinitionParserTest {
|
||||
{ "id": "hr", "name": "HR approval", "candidates": ["henry"] }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": "hr", "when": "high", "priority": 1 },
|
||||
{ "from": "manager", "to": null, "when": "low", "priority": 1 },
|
||||
{ "from": "manager", "to": "hr", "when": { "ref": "high" }, "priority": 1 },
|
||||
{ "from": "manager", "to": null, "when": { "ref": "low" }, "priority": 1 },
|
||||
{ "from": "hr", "to": null }
|
||||
]
|
||||
}
|
||||
@@ -179,6 +179,23 @@ class ProcessDefinitionParserTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStringWhen() {
|
||||
String json = """
|
||||
{
|
||||
"id": "leave",
|
||||
"name": "Leave request",
|
||||
"steps": [
|
||||
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": null, "when": "days-gt-3" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(json));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesActionStepsAndDefaultsKindToApproval() {
|
||||
String json = """
|
||||
|
||||
@@ -81,8 +81,8 @@ class ProcessDefinitionTest {
|
||||
ApprovalStep.single("hr", "HR approval", "henry"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
|
||||
List.of(
|
||||
StepTransition.when("manager", "hr", "high", 1),
|
||||
StepTransition.endWhen("manager", "low", 1))));
|
||||
StepTransition.when("manager", "hr", RoutingWhen.ref("high"), 1),
|
||||
StepTransition.endWhen("manager", RoutingWhen.ref("low"), 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class RoutingWhenTest {
|
||||
|
||||
@Test
|
||||
void parsesPredicateAndRef() {
|
||||
RoutingWhen predicate = RoutingWhen.parseJson("{\"gt\":[\"days\",3]}");
|
||||
assertInstanceOf(RoutingWhen.Predicate.class, predicate);
|
||||
RoutingWhen ref = RoutingWhen.parseJson("{\"ref\":\"amountGt\",\"args\":{\"threshold\":50000}}");
|
||||
RoutingWhen.Ref parsed = assertInstanceOf(RoutingWhen.Ref.class, ref);
|
||||
assertEquals("amountGt", parsed.key());
|
||||
assertEquals(50000, ((Number) parsed.args().get("threshold")).intValue());
|
||||
assertEquals(predicate, RoutingWhen.parseJson(predicate.toJson()));
|
||||
assertEquals(ref, RoutingWhen.parseJson(ref.toJson()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMixedRefAndOperator() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> RoutingWhen.parseJson("{\"ref\":\"x\",\"gt\":[\"days\",1]}"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user