refactor(bpmn): split BpmnTestProcesses by construct family (#7964)

BpmnTestProcesses had grown to 905 lines as one flat static class holding the
fixtures for every BPMN construct family the runtime slice covers, and two
standards reviews flagged it as Divergent Change while judging the split out of
scope for the issue in hand.

It becomes a partial class across six sibling files, one per family -- boundary
events, compensation and transactions, event subprocesses, flow and gateways,
multi-instance -- with the shared element factories (Timer, Cancel, Compensation,
CompensationBoundary, Escalation, Error, Message, EventSubprocess,
EventSubprocessStart) and the Scope/Immediate/Blocking/Faulting builders left in
one place, so no new file duplicates them. Partial rather than separate types
because every call site says BpmnTestProcesses.X and none of them change.

Pure move: all 47 members were carved out programmatically and diffed back
against HEAD, each present exactly once and byte-identical. In particular
EscalationOutOfSubprocess keeps its leading subFirst work item and the comment
explaining why the nested scope's handle counter must run ahead of its parent's.

The identical Compensation/CompensationBoundary helpers in
Elsa.Bpmn.Interchange.IntegrationTests are deliberately left duplicated: the only
assembly both test projects can see is Elsa.Testing.Shared.Integration, which
ships as a NuGet package, so sharing ten lines of test helper would mean adding
an Elsa.Bpmn reference to a published package's dependency graph.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sipke Schoorstra 2026-08-20 22:30:17 +02:00 committed by GitHub
parent fe83d2b385
commit 0a86a4803e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 864 additions and 820 deletions

View file

@ -0,0 +1,123 @@
using Bpmn.Model;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
internal static partial class BpmnTestProcesses
{
/// <summary>An interrupting timer boundary event on a long-running task.</summary>
public static BpmnProcess InterruptingTimerBoundary(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("interrupting-timer-boundary")
.StartEvent("start")
.Task("task", bindingRef: BindingRef("task"))
.EndEvent("end")
.BoundaryEvent("timeout", attachedTo: "task", eventDefinition: Timer(), interrupting: true, bindingRef: BindingRef("timeout"))
.Task("onTimeout", bindingRef: BindingRef("onTimeout"))
.EndEvent("timedOut")
.ConnectSequence("start", "task", "end")
.ConnectSequence("timeout", "onTimeout", "timedOut")
.Build();
return Scope("scope", definition, Blocking("task", log), Blocking("timeout", log), Immediate("onTimeout", log));
}
/// <summary>A task that fails, with an error boundary event that catches it.</summary>
public static BpmnProcess ErrorBoundaryCaught(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("error-boundary-caught")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.EndEvent("end")
.BoundaryEvent("oops", attachedTo: "risky", eventDefinition: new BpmnEventDefinition(BpmnEventDefinitionTypes.Error))
.Task("recover", bindingRef: BindingRef("recover"))
.EndEvent("recovered")
.ConnectSequence("start", "risky", "end")
.ConnectSequence("oops", "recover", "recovered")
.Build();
return Scope("scope", definition, Faulting("risky", log), Immediate("recover", log));
}
/// <summary>
/// A task that fails inside an embedded subprocess with nothing there to catch it, and an error boundary event on
/// the subprocess in the enclosing scope that does.
/// </summary>
public static BpmnProcess ErrorPropagatedOutOfSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("failing-subprocess-body")
.StartEvent("subStart")
.Task("subRisky", bindingRef: BindingRef("subRisky"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subRisky", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("error-propagated-out-of-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.BoundaryEvent("subOops", attachedTo: "sub", eventDefinition: new BpmnEventDefinition(BpmnEventDefinitionTypes.Error))
.Task("subRecover", bindingRef: BindingRef("subRecover"))
.EndEvent("subRecovered")
.ConnectSequence("start", "sub", "after", "end")
.ConnectSequence("subOops", "subRecover", "subRecovered")
.Build();
var nested = Scope("sub", body, Faulting("subRisky", log));
return Scope("scope", definition, nested, Immediate("after", log), Immediate("subRecover", log));
}
/// <summary>A task that fails with nothing to catch it.</summary>
public static BpmnProcess UncaughtError(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("uncaught-error")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.EndEvent("end")
.ConnectSequence("start", "risky", "end")
.Build();
return Scope("scope", definition, Faulting("risky", log));
}
/// <summary>
/// An escalation thrown out of an embedded subprocess, caught by a non-interrupting escalation boundary event on
/// the subprocess. The nested scope keeps running, which is what "non-interrupting" means.
/// </summary>
public static BpmnProcess EscalationOutOfSubprocess(BpmnTestLog log)
{
// subFirst runs before anything the parent could be confused with, deliberately: it puts the nested scope's
// handle counter ahead of the parent's. A host that recognised its work by a shared, rewritable key rather than
// by the child activity execution would otherwise be rescued by two independent counters happening to agree.
var body = new BpmnProcessBuilder("subprocess-body")
.StartEvent("subStart")
.Task("subFirst", bindingRef: BindingRef("subFirst"))
.Task("subWork", bindingRef: BindingRef("subWork"))
.IntermediateThrowEvent("subEscalate", Escalation("REVIEW"))
.Task("subMore", bindingRef: BindingRef("subMore"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subFirst", "subWork", "subEscalate", "subMore", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("escalation-out-of-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.BoundaryEvent("escalated", attachedTo: "sub", eventDefinition: Escalation("REVIEW"), interrupting: false)
.Task("notify", bindingRef: BindingRef("notify"))
.EndEvent("notified")
.ConnectSequence("start", "sub", "after", "end")
.ConnectSequence("escalated", "notify", "notified")
.Build();
// subMore blocks so the subprocess is still running when the escalation path executes, which is what makes
// "the escalating work is still live" observable rather than merely asserted.
var nested = Scope("sub", body, Immediate("subFirst", log), Blocking("subWork", log), Blocking("subMore", log));
return Scope("scope", definition, nested, Immediate("after", log), Immediate("notify", log));
}
}

View file

@ -0,0 +1,256 @@
using Bpmn.Model;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
internal static partial class BpmnTestProcesses
{
/// <summary>
/// Three bookings, each carrying a compensation boundary event, and a compensate end event that replays the lot.
/// </summary>
/// <remarks>
/// Three rather than two: with two, a handler order that merely happened to be reversed is indistinguishable from
/// one that swapped a pair, and a replay that walked the log forwards would still run every handler. The three
/// handlers bind work like anything else, and nothing in the graph flows into them — the only thing that can run
/// them is the replay.
/// </remarks>
public static BpmnProcess CompensatedBookings(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("compensated-bookings")
.StartEvent("start")
.Task("bookFlight", bindingRef: BindingRef("bookFlight"))
.Task("bookHotel", bindingRef: BindingRef("bookHotel"))
.Task("bookCar", bindingRef: BindingRef("bookCar"))
.EndEvent("undoEverything", null, Compensation())
.Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
.Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
.Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
.Element(CompensationHandler("undoFlight"))
.Element(CompensationHandler("undoHotel"))
.Element(CompensationHandler("undoCar"))
.ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoEverything")
.Build();
return Scope(
"scope",
definition,
Immediate("bookFlight", log),
Immediate("bookHotel", log),
Immediate("bookCar", log),
Immediate("undoFlight", log),
Immediate("undoHotel", log),
Immediate("undoCar", log));
}
/// <summary>
/// The same three bookings, compensated by an intermediate throw event naming one of them in its
/// <c>activityRef</c>, and a task after the throw that its outbound flow reaches once the replay is done.
/// </summary>
public static BpmnProcess TargetedCompensation(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("targeted-compensation")
.StartEvent("start")
.Task("bookFlight", bindingRef: BindingRef("bookFlight"))
.Task("bookHotel", bindingRef: BindingRef("bookHotel"))
.Task("bookCar", bindingRef: BindingRef("bookCar"))
.IntermediateThrowEvent("undoHotelOnly", Compensation(activityRef: "bookHotel"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
.Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
.Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
.Element(CompensationHandler("undoFlight"))
.Element(CompensationHandler("undoHotel"))
.Element(CompensationHandler("undoCar"))
.ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoHotelOnly", "after", "end")
.Build();
return Scope(
"scope",
definition,
Immediate("bookFlight", log),
Immediate("bookHotel", log),
Immediate("bookCar", log),
Immediate("undoFlight", log),
Immediate("undoHotel", log),
Immediate("undoCar", log),
Immediate("after", log));
}
/// <summary>
/// A compensation log inside an embedded subprocess, and a second one in the enclosing scope that compensates the
/// subprocess itself.
/// </summary>
/// <remarks>
/// Two logs, one per scope, and neither can see the other: the body replays its own two handlers before it
/// completes, and the enclosing scope registers exactly one entry — the subprocess's own successful completion,
/// which its attached compensation boundary makes compensable — and replays that.
/// </remarks>
public static BpmnProcess CompensationInsideSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("compensating-subprocess-body")
.StartEvent("subStart")
.Task("subCharge", bindingRef: BindingRef("subCharge"))
.Task("subShip", bindingRef: BindingRef("subShip"))
.EndEvent("subUndo", null, Compensation())
.Element(CompensationBoundary("subChargeCompensated", attachedTo: "subCharge", handler: "subRefund"))
.Element(CompensationBoundary("subShipCompensated", attachedTo: "subShip", handler: "subRecall"))
.Element(CompensationHandler("subRefund"))
.Element(CompensationHandler("subRecall"))
.ConnectSequence("subStart", "subCharge", "subShip", "subUndo")
.Build();
var definition = new BpmnProcessBuilder("compensation-inside-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.EndEvent("undoOuter", null, Compensation())
.Element(CompensationBoundary("subCompensated", attachedTo: "sub", handler: "undoSub"))
.Element(CompensationHandler("undoSub"))
.ConnectSequence("start", "sub", "undoOuter")
.Build();
var nested = Scope(
"sub",
body,
Immediate("subCharge", log),
Immediate("subShip", log),
Immediate("subRefund", log),
Immediate("subRecall", log));
return Scope("scope", definition, nested, Immediate("undoSub", log));
}
/// <summary>
/// A transaction subprocess that cancels itself from the inside, and a cancel boundary event on the transaction
/// that routes the cancellation.
/// </summary>
/// <remarks>
/// The nested scope completes with the <c>Cancelled</c> outcome rather than <c>Done</c>, and the enclosing scope
/// only reaches the boundary path if that outcome survives the trip through the parent's completion callback.
/// Nothing else in the process distinguishes the two: with the outcome dropped the parent simply carries on down
/// the ordinary sequence flow, which is a completion that looks entirely successful.
/// </remarks>
public static BpmnProcess CancelledTransactionSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("transaction-body")
.Transaction()
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.EndEvent("subCancelled", null, Cancel())
.ConnectSequence("subStart", "subWork", "subCancelled")
.Build();
var definition = new BpmnProcessBuilder("cancelled-transaction-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel())
.Task("unwind", bindingRef: BindingRef("unwind"))
.EndEvent("unwound")
.ConnectSequence("start", "sub", "after", "end")
.ConnectSequence("cancelled", "unwind", "unwound")
.Build();
var nested = Scope("sub", body, Immediate("subWork", log));
return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
}
/// <summary>
/// A transaction subprocess that cancels itself from the inside, with nothing on the enclosing scope to route the
/// cancellation.
/// </summary>
/// <remarks>
/// The same shape as <see cref="CancelledTransactionSubprocess"/> minus the cancel boundary event. Graph
/// validation cannot see into the nested definition to know a cancel end event is in there, so an unroutable
/// cancellation is an execution-time rule: the enclosing scope faults rather than treating the transaction as an
/// ordinary completion and carrying on down the sequence flow.
/// </remarks>
public static BpmnProcess CancelledTransactionWithoutCancelBoundary(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("unroutable-cancel-body")
.Transaction()
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.EndEvent("subCancelled", null, Cancel())
.ConnectSequence("subStart", "subWork", "subCancelled")
.Build();
var definition = new BpmnProcessBuilder("unroutable-cancel")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope("sub", body, Immediate("subWork", log));
return Scope("scope", definition, nested, Immediate("after", log));
}
/// <summary>
/// A transaction subprocess that starts a compensation replay on one branch and cancels itself on the other while
/// that replay is still running, so the replay's claimed-but-unrun log entries are torn down mid-run.
/// </summary>
/// <remarks>
/// <para>
/// The shape is what makes the release observable. <c>chargeCard</c> and <c>reserveSeat</c> both complete and
/// register, in that order, so the replay the intermediate throw opens claims both and runs them in reverse:
/// <c>releaseSeat</c> first, which blocks, leaving <c>refundCard</c> claimed and never started. Cancelling the
/// transaction from the other branch stops the replay's coordinating token, which is the only thing in scope that
/// tears a run down mid-flight.
/// </para>
/// <para>
/// <c>fraudCheck</c> blocks so a test decides when the cancellation happens, rather than racing the replay.
/// </para>
/// </remarks>
public static BpmnProcess CompensationRunCancelledMidReplay(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("mid-replay-cancel-body")
.Transaction()
.StartEvent("subStart")
.ParallelGateway("subSplit")
.Task("chargeCard", bindingRef: BindingRef("chargeCard"))
.Task("reserveSeat", bindingRef: BindingRef("reserveSeat"))
.IntermediateThrowEvent("rollBack", Compensation())
.EndEvent("rolledBack")
.Task("fraudCheck", bindingRef: BindingRef("fraudCheck"))
.EndEvent("subCancelled", null, Cancel())
.Element(CompensationBoundary("cardCompensated", attachedTo: "chargeCard", handler: "refundCard"))
.Element(CompensationBoundary("seatCompensated", attachedTo: "reserveSeat", handler: "releaseSeat"))
.Element(CompensationHandler("refundCard"))
.Element(CompensationHandler("releaseSeat"))
.ConnectSequence("subStart", "subSplit")
.Connect("subSplit", "chargeCard")
.ConnectSequence("chargeCard", "reserveSeat", "rollBack", "rolledBack")
.Connect("subSplit", "fraudCheck")
.ConnectSequence("fraudCheck", "subCancelled")
.Build();
var definition = new BpmnProcessBuilder("mid-replay-cancel")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel())
.Task("unwind", bindingRef: BindingRef("unwind"))
.EndEvent("unwound")
.ConnectSequence("start", "sub", "after", "end")
.ConnectSequence("cancelled", "unwind", "unwound")
.Build();
var nested = Scope(
"sub",
body,
Immediate("chargeCard", log),
Immediate("reserveSeat", log),
Blocking("fraudCheck", log),
Immediate("refundCard", log),
Blocking("releaseSeat", log));
return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
}
}

View file

@ -0,0 +1,297 @@
using Bpmn.Model;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
using Elsa.Workflows;
namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
internal static partial class BpmnTestProcesses
{
/// <summary>
/// A task that fails, with a dormant error-triggered event subprocess in the same scope to catch it.
/// </summary>
/// <remarks>
/// An error event subprocess arms nothing: it rides the same <c>FaultSignal</c> seam an error boundary event does,
/// and the only thing that distinguishes it here is where the recovery work runs — inside a nested scope of its
/// own, seeded at the body's error start event, rather than on an outbound flow of the enclosing graph.
/// </remarks>
public static BpmnProcess ErrorEventSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("error-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error()))
.Task("handleError", bindingRef: BindingRef("handleError"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "errEnd")
.Build();
var definition = new BpmnProcessBuilder("error-event-subprocess")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.Element(EventSubprocess("evtSub"))
.ConnectSequence("start", "risky", "after", "end")
.Build();
return Scope("scope", definition, Faulting("risky", log), Immediate("after", log), Scope("evtSub", body, Immediate("handleError", log)));
}
/// <summary>
/// An escalation thrown out of an embedded subprocess, caught by a non-interrupting escalation-triggered event
/// subprocess on the enclosing scope rather than by a boundary event on the subprocess.
/// </summary>
/// <remarks>
/// Non-interrupting, so the escalating subprocess keeps running and nothing in the scope is torn down. That is
/// also what makes "the scope-level catcher fired" distinguishable from "the subprocess was stopped": with an
/// interrupting catcher the two are the same observation.
/// </remarks>
public static BpmnProcess EscalationEventSubprocessOutOfSubprocess(BpmnTestLog log)
{
var subBody = new BpmnProcessBuilder("escalating-subprocess-body")
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.IntermediateThrowEvent("subEscalate", Escalation("REVIEW"))
.Task("subMore", bindingRef: BindingRef("subMore"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subWork", "subEscalate", "subMore", "subEnd")
.Build();
var handlerBody = new BpmnProcessBuilder("escalation-event-subprocess-body")
.Element(EventSubprocessStart("escStart", Escalation("REVIEW"), interrupting: false))
.Task("handleEscalation", bindingRef: BindingRef("handleEscalation"))
.EndEvent("escEnd")
.ConnectSequence("escStart", "handleEscalation", "escEnd")
.Build();
var definition = new BpmnProcessBuilder("escalation-event-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.Element(EventSubprocess("evtSub"))
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope("sub", subBody, Blocking("subWork", log), Blocking("subMore", log));
return Scope("scope", definition, nested, Immediate("after", log), Scope("evtSub", handlerBody, Immediate("handleEscalation", log)));
}
/// <summary>
/// A non-interrupting message-triggered event subprocess: a listener armed at scope start, and a body that runs
/// each time the listener fires while the scope's own long-running work is still going.
/// </summary>
/// <remarks>
/// <para>
/// The listener is the second binding channel — <c>listenerBindingRef</c> — and is bound in the same
/// <c>WorkBindings</c> map as everything else. It stands in for a real message wait: blocking work a test
/// finishes, which is exactly what "the trigger fired" means to the host.
/// </para>
/// <para>
/// <c>work</c> blocks so the scope stays open across the fires and so that when it finally completes, the armed
/// listener is a <em>running</em> activity rather than a scheduled-but-not-yet-invoked one — the second of which
/// this host cannot withdraw at all.
/// </para>
/// </remarks>
public static BpmnProcess MessageEventSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("message-event-subprocess-body")
.Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
.Task("handleNudge", bindingRef: BindingRef("handleNudge"))
.EndEvent("msgEnd")
.ConnectSequence("msgStart", "handleNudge", "msgEnd")
.Build();
var definition = new BpmnProcessBuilder("message-event-subprocess")
.StartEvent("start")
.Task("work", bindingRef: BindingRef("work"))
.EndEvent("end")
.Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
.ConnectSequence("start", "work", "end")
.Build();
return Scope(
"scope",
definition,
Blocking("work", log),
Blocking("nudgeListener", log),
Scope("evtSub", body, Immediate("handleNudge", log)));
}
/// <summary>
/// The same message-triggered event subprocess, but inside an embedded subprocess that completes while the
/// enclosing scope carries on — so a listener that outlived the scope that armed it is distinguishable from one
/// that merely outlived the workflow.
/// </summary>
/// <remarks>
/// At the root, "the armed work does not survive the scope" and "does not survive the workflow" are the same
/// observation, and Elsa tears a finished workflow's children down regardless. Here the workflow keeps running
/// after the scope that armed the listener has completed, which is the only shape in which a listener left behind
/// is a listener something could still resume into.
/// </remarks>
public static BpmnProcess NestedMessageEventSubprocess(BpmnTestLog log)
{
var handlerBody = new BpmnProcessBuilder("nested-message-event-subprocess-body")
.Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
.Task("handleNudge", bindingRef: BindingRef("handleNudge"))
.EndEvent("msgEnd")
.ConnectSequence("msgStart", "handleNudge", "msgEnd")
.Build();
var subBody = new BpmnProcessBuilder("listening-subprocess-body")
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.EndEvent("subEnd")
.Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
.ConnectSequence("subStart", "subWork", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("nested-message-event-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope(
"sub",
subBody,
Blocking("subWork", log),
Blocking("nudgeListener", log),
Scope("evtSub", handlerBody, Immediate("handleNudge", log)));
return Scope("scope", definition, nested, Immediate("after", log));
}
/// <summary>
/// An error-triggered event subprocess whose body runs an ordinary embedded subprocess of its own, so the
/// start-element hint has both a place to arrive and a place it must not reach.
/// </summary>
/// <remarks>
/// <para>
/// The body's only start event is event-defined, which is what makes the hint's arrival observable rather than
/// merely asserted: seeded from the hint the body runs, and seeded as an ordinary direct invocation it faults
/// deterministically with <c>bpmn.start.none-available</c>, because there is no none start event to begin at.
/// </para>
/// <para>
/// The nested <c>inner</c> subprocess is the other direction. Its own invocation carries an ordinary scheduling
/// cause, so the hint must not be inherited: were it, the inner process would be seeded at an element it does not
/// declare and fault with <c>bpmn.start.unresolved-hint</c> instead of starting at its own none start event.
/// </para>
/// </remarks>
public static BpmnProcess EventSubprocessBodyWithNestedSubprocess(BpmnTestLog log)
{
var innerBody = new BpmnProcessBuilder("event-subprocess-inner-body")
.StartEvent("innerStart")
.Task("innerOnly", bindingRef: BindingRef("innerOnly"))
.EndEvent("innerEnd")
.ConnectSequence("innerStart", "innerOnly", "innerEnd")
.Build();
var body = new BpmnProcessBuilder("hinted-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error()))
.Task("handleError", bindingRef: BindingRef("handleError"))
.SubProcess("inner", bindingRef: BindingRef("inner"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "inner", "errEnd")
.Build();
var definition = new BpmnProcessBuilder("event-subprocess-start-hint")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.EndEvent("end")
.Element(EventSubprocess("evtSub"))
.ConnectSequence("start", "risky", "end")
.Build();
var handler = Scope("evtSub", body, Immediate("handleError", log), Scope("inner", innerBody, Immediate("innerOnly", log)));
return Scope("scope", definition, Faulting("risky", log), handler);
}
/// <summary>An event subprocess whose body declares two start events, which the library refuses.</summary>
public static BpmnProcess EventSubprocessBodyWithTwoStartEvents(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("two-start-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error()))
.StartEvent("alsoStart")
.Task("handleError", bindingRef: BindingRef("handleError"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "errEnd")
.Connect("alsoStart", "handleError")
.Build();
return RefusedEventSubprocessScope("two-start-events", log, ("evtSub", body, "handleError"));
}
/// <summary>Two error-triggered event subprocesses in one scope, which the library refuses.</summary>
public static BpmnProcess TwoErrorEventSubprocesses(BpmnTestLog log)
{
BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-error-event-subprocess-body")
.Element(EventSubprocessStart($"{prefix}Start", Error()))
.Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
.EndEvent($"{prefix}End")
.ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
.Build();
return RefusedEventSubprocessScope(
"two-error-event-subprocesses",
log,
("evtSubA", Body("first"), "firstHandle"),
("evtSubB", Body("second"), "secondHandle"));
}
/// <summary>Two code-less catch-all escalation-triggered event subprocesses in one scope, which the library refuses.</summary>
public static BpmnProcess TwoCatchAllEscalationEventSubprocesses(BpmnTestLog log)
{
BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-escalation-event-subprocess-body")
.Element(EventSubprocessStart($"{prefix}Start", Escalation(), interrupting: false))
.Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
.EndEvent($"{prefix}End")
.ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
.Build();
return RefusedEventSubprocessScope(
"two-catch-all-escalation-event-subprocesses",
log,
("evtSubA", Body("first"), "firstHandle"),
("evtSubB", Body("second"), "secondHandle"));
}
/// <summary>A non-interrupting error-triggered event subprocess, which is not legal BPMN and which the library refuses.</summary>
public static BpmnProcess NonInterruptingErrorEventSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("non-interrupting-error-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error(), interrupting: false))
.Task("handleError", bindingRef: BindingRef("handleError"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "errEnd")
.Build();
return RefusedEventSubprocessScope("non-interrupting-error-event-subprocess", log, ("evtSub", body, "handleError"));
}
/// <summary>
/// The <c>start/only/end</c> graph the refusal processes share, carrying the event subprocesses whose declaration
/// the library refuses. Nothing in it ever runs: the refusal is raised when the scope builds its graph, which is
/// before any work is started.
/// </summary>
private static BpmnProcess RefusedEventSubprocessScope(string processId, BpmnTestLog log, params (string ElementId, BpmnProcessDefinition Body, string HandlerId)[] eventSubprocesses)
{
var builder = new BpmnProcessBuilder(processId)
.StartEvent("start")
.Task("only", bindingRef: BindingRef("only"))
.EndEvent("end")
.ConnectSequence("start", "only", "end");
foreach (var eventSubprocess in eventSubprocesses)
builder = builder.Element(EventSubprocess(eventSubprocess.ElementId));
var work = new List<IActivity> { Immediate("only", log) };
work.AddRange(eventSubprocesses.Select(eventSubprocess => Scope(eventSubprocess.ElementId, eventSubprocess.Body, Immediate(eventSubprocess.HandlerId, log))));
return Scope("scope", builder.Build(), work.ToArray());
}
}

View file

@ -0,0 +1,116 @@
using Bpmn.Model;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
using Elsa.Workflows;
namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
internal static partial class BpmnTestProcesses
{
/// <summary>A linear process: one task between a start and an end event.</summary>
public static BpmnProcess LinearTask(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("linear-task")
.StartEvent("start")
.Task("only", bindingRef: BindingRef("only"))
.EndEvent("end")
.ConnectSequence("start", "only", "end")
.Build();
return Scope("scope", definition, Immediate("only", log));
}
/// <summary>An embedded subprocess with one task in it, and one task after it in the enclosing scope.</summary>
public static BpmnProcess NestedSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("nested-subprocess-body")
.StartEvent("subStart")
.Task("subOnly", bindingRef: BindingRef("subOnly"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subOnly", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("nested-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
return Scope("scope", definition, Scope("sub", body, Immediate("subOnly", log)), Immediate("after", log));
}
/// <summary>A parallel gateway split and join.</summary>
public static BpmnProcess ParallelSplitAndJoin(BpmnTestLog log) =>
ParallelSplitAndJoinTopology("parallel-split-and-join", Immediate("left", log), Immediate("right", log), log);
/// <summary>
/// A parallel split into two branches that both block, and a join. Used to prove a scope suspends with two live
/// units of work outstanding and, once resumed, matches each completion back to its own binding through the
/// rehydrated ledger.
/// </summary>
public static BpmnProcess ParallelSplitAndJoinBlocking(BpmnTestLog log) =>
ParallelSplitAndJoinTopology("parallel-split-and-join-blocking", Blocking("left", log), Blocking("right", log), log);
/// <summary>
/// The start/split/left/right/join/after/end graph shared by <see cref="ParallelSplitAndJoin"/> and
/// <see cref="ParallelSplitAndJoinBlocking"/>, parameterised by the work the two branches run.
/// </summary>
private static BpmnProcess ParallelSplitAndJoinTopology(string processId, IActivity leftWork, IActivity rightWork, BpmnTestLog log)
{
var definition = new BpmnProcessBuilder(processId)
.StartEvent("start")
.ParallelGateway("split")
.Task("left", bindingRef: BindingRef("left"))
.Task("right", bindingRef: BindingRef("right"))
.ParallelGateway("join")
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "split")
.Connect("split", "left")
.Connect("split", "right")
.Connect("left", "join")
.Connect("right", "join")
.ConnectSequence("join", "after", "end")
.Build();
return Scope("scope", definition, leftWork, rightWork, Immediate("after", log));
}
/// <summary>
/// A parallel split and join, blocking on both branches, nested inside an embedded subprocess. Used to prove
/// a <em>nested</em> scope's own ledger -- not just a root scope's -- matches each completion back to its
/// binding after a round trip through Elsa's own serializer.
/// </summary>
public static BpmnProcess NestedParallelSplitAndJoinBlocking(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("nested-parallel-split-and-join-body")
.StartEvent("subStart")
.ParallelGateway("subSplit")
.Task("subLeft", bindingRef: BindingRef("subLeft"))
.Task("subRight", bindingRef: BindingRef("subRight"))
.ParallelGateway("subJoin")
.Task("subAfter", bindingRef: BindingRef("subAfter"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subSplit")
.Connect("subSplit", "subLeft")
.Connect("subSplit", "subRight")
.Connect("subLeft", "subJoin")
.Connect("subRight", "subJoin")
.ConnectSequence("subJoin", "subAfter", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("nested-parallel-split-and-join-blocking")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope("sub", body, Blocking("subLeft", log), Blocking("subRight", log), Immediate("subAfter", log));
return Scope("scope", definition, nested, Immediate("after", log));
}
}

View file

@ -0,0 +1,69 @@
using Bpmn.Model;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
using Elsa.Workflows.Memory;
namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
internal static partial class BpmnTestProcesses
{
/// <summary>The name of the variable <see cref="CollectionMultiInstanceTask"/> loops over.</summary>
public const string CollectionVariableName = "items";
/// <summary>
/// A parallel multi-instance task: two concurrent instances of one binding, told apart only by their iteration id.
/// </summary>
public static BpmnProcess ParallelMultiInstanceTask(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("parallel-multi-instance-task")
.StartEvent("start")
.Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, cardinality: 2))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "each", "after", "end")
.Build();
return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
}
/// <summary>
/// A sequential multi-instance task: one instance at a time, each blocking until a test finishes it. Used to
/// drive many evaluations of one scope so a persisted blob that is not pruned is observable as unbounded growth.
/// </summary>
public static BpmnProcess SequentialMultiInstanceTask(BpmnTestLog log, int cardinality)
{
var definition = new BpmnProcessBuilder("sequential-multi-instance-task")
.StartEvent("start")
.Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: true, cardinality: cardinality))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "each", "after", "end")
.Build();
return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
}
/// <summary>
/// A collection-mode multi-instance task: one instance per item of a container-scoped variable, which the
/// interpreter reads back through <c>IBpmnVariableReader</c> while it evaluates.
/// </summary>
/// <remarks>
/// Three items rather than two, so the instance count cannot be confused with a declared cardinality. The
/// collection variable is declared on both sides — on the definition, because <c>BpmnGraph.Build</c> refuses a
/// loop naming a variable the process does not declare, and on the activity, because that is where the value
/// actually lives.
/// </remarks>
public static BpmnProcess CollectionMultiInstanceTask(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("collection-multi-instance-task")
.Variable(CollectionVariableName)
.StartEvent("start")
.Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, collectionVariable: CollectionVariableName))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "each", "after", "end")
.Build();
return Scope("scope", definition, [new Variable<string[]>(CollectionVariableName, ["alpha", "beta", "gamma"])], Immediate("each", log), Immediate("after", log));
}
}

View file

@ -11,831 +11,14 @@ namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
/// </summary>
/// <remarks>
/// Every activity's id is the BPMN element id it runs, and its binding ref is that id prefixed, so a process reads
/// the same way in the model and in the assertions.
/// the same way in the model and in the assertions. The processes themselves live in sibling partials, one per BPMN
/// construct family; this file holds the element factories and scope builders they all share.
/// </remarks>
internal static class BpmnTestProcesses
internal static partial class BpmnTestProcesses
{
/// <summary>An interrupting timer boundary event on a long-running task.</summary>
public static BpmnProcess InterruptingTimerBoundary(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("interrupting-timer-boundary")
.StartEvent("start")
.Task("task", bindingRef: BindingRef("task"))
.EndEvent("end")
.BoundaryEvent("timeout", attachedTo: "task", eventDefinition: Timer(), interrupting: true, bindingRef: BindingRef("timeout"))
.Task("onTimeout", bindingRef: BindingRef("onTimeout"))
.EndEvent("timedOut")
.ConnectSequence("start", "task", "end")
.ConnectSequence("timeout", "onTimeout", "timedOut")
.Build();
return Scope("scope", definition, Blocking("task", log), Blocking("timeout", log), Immediate("onTimeout", log));
}
/// <summary>
/// An escalation thrown out of an embedded subprocess, caught by a non-interrupting escalation boundary event on
/// the subprocess. The nested scope keeps running, which is what "non-interrupting" means.
/// </summary>
public static BpmnProcess EscalationOutOfSubprocess(BpmnTestLog log)
{
// subFirst runs before anything the parent could be confused with, deliberately: it puts the nested scope's
// handle counter ahead of the parent's. A host that recognised its work by a shared, rewritable key rather than
// by the child activity execution would otherwise be rescued by two independent counters happening to agree.
var body = new BpmnProcessBuilder("subprocess-body")
.StartEvent("subStart")
.Task("subFirst", bindingRef: BindingRef("subFirst"))
.Task("subWork", bindingRef: BindingRef("subWork"))
.IntermediateThrowEvent("subEscalate", Escalation("REVIEW"))
.Task("subMore", bindingRef: BindingRef("subMore"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subFirst", "subWork", "subEscalate", "subMore", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("escalation-out-of-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.BoundaryEvent("escalated", attachedTo: "sub", eventDefinition: Escalation("REVIEW"), interrupting: false)
.Task("notify", bindingRef: BindingRef("notify"))
.EndEvent("notified")
.ConnectSequence("start", "sub", "after", "end")
.ConnectSequence("escalated", "notify", "notified")
.Build();
// subMore blocks so the subprocess is still running when the escalation path executes, which is what makes
// "the escalating work is still live" observable rather than merely asserted.
var nested = Scope("sub", body, Immediate("subFirst", log), Blocking("subWork", log), Blocking("subMore", log));
return Scope("scope", definition, nested, Immediate("after", log), Immediate("notify", log));
}
/// <summary>A parallel gateway split and join.</summary>
public static BpmnProcess ParallelSplitAndJoin(BpmnTestLog log) =>
ParallelSplitAndJoinTopology("parallel-split-and-join", Immediate("left", log), Immediate("right", log), log);
/// <summary>A task that fails, with an error boundary event that catches it.</summary>
public static BpmnProcess ErrorBoundaryCaught(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("error-boundary-caught")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.EndEvent("end")
.BoundaryEvent("oops", attachedTo: "risky", eventDefinition: new BpmnEventDefinition(BpmnEventDefinitionTypes.Error))
.Task("recover", bindingRef: BindingRef("recover"))
.EndEvent("recovered")
.ConnectSequence("start", "risky", "end")
.ConnectSequence("oops", "recover", "recovered")
.Build();
return Scope("scope", definition, Faulting("risky", log), Immediate("recover", log));
}
/// <summary>
/// A task that fails inside an embedded subprocess with nothing there to catch it, and an error boundary event on
/// the subprocess in the enclosing scope that does.
/// </summary>
public static BpmnProcess ErrorPropagatedOutOfSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("failing-subprocess-body")
.StartEvent("subStart")
.Task("subRisky", bindingRef: BindingRef("subRisky"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subRisky", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("error-propagated-out-of-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.BoundaryEvent("subOops", attachedTo: "sub", eventDefinition: new BpmnEventDefinition(BpmnEventDefinitionTypes.Error))
.Task("subRecover", bindingRef: BindingRef("subRecover"))
.EndEvent("subRecovered")
.ConnectSequence("start", "sub", "after", "end")
.ConnectSequence("subOops", "subRecover", "subRecovered")
.Build();
var nested = Scope("sub", body, Faulting("subRisky", log));
return Scope("scope", definition, nested, Immediate("after", log), Immediate("subRecover", log));
}
/// <summary>A task that fails with nothing to catch it.</summary>
public static BpmnProcess UncaughtError(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("uncaught-error")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.EndEvent("end")
.ConnectSequence("start", "risky", "end")
.Build();
return Scope("scope", definition, Faulting("risky", log));
}
/// <summary>
/// A parallel multi-instance task: two concurrent instances of one binding, told apart only by their iteration id.
/// </summary>
public static BpmnProcess ParallelMultiInstanceTask(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("parallel-multi-instance-task")
.StartEvent("start")
.Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, cardinality: 2))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "each", "after", "end")
.Build();
return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
}
/// <summary>
/// A sequential multi-instance task: one instance at a time, each blocking until a test finishes it. Used to
/// drive many evaluations of one scope so a persisted blob that is not pruned is observable as unbounded growth.
/// </summary>
public static BpmnProcess SequentialMultiInstanceTask(BpmnTestLog log, int cardinality)
{
var definition = new BpmnProcessBuilder("sequential-multi-instance-task")
.StartEvent("start")
.Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: true, cardinality: cardinality))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "each", "after", "end")
.Build();
return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
}
/// <summary>
/// A parallel split into two branches that both block, and a join. Used to prove a scope suspends with two live
/// units of work outstanding and, once resumed, matches each completion back to its own binding through the
/// rehydrated ledger.
/// </summary>
public static BpmnProcess ParallelSplitAndJoinBlocking(BpmnTestLog log) =>
ParallelSplitAndJoinTopology("parallel-split-and-join-blocking", Blocking("left", log), Blocking("right", log), log);
/// <summary>
/// The start/split/left/right/join/after/end graph shared by <see cref="ParallelSplitAndJoin"/> and
/// <see cref="ParallelSplitAndJoinBlocking"/>, parameterised by the work the two branches run.
/// </summary>
private static BpmnProcess ParallelSplitAndJoinTopology(string processId, IActivity leftWork, IActivity rightWork, BpmnTestLog log)
{
var definition = new BpmnProcessBuilder(processId)
.StartEvent("start")
.ParallelGateway("split")
.Task("left", bindingRef: BindingRef("left"))
.Task("right", bindingRef: BindingRef("right"))
.ParallelGateway("join")
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "split")
.Connect("split", "left")
.Connect("split", "right")
.Connect("left", "join")
.Connect("right", "join")
.ConnectSequence("join", "after", "end")
.Build();
return Scope("scope", definition, leftWork, rightWork, Immediate("after", log));
}
/// <summary>
/// A collection-mode multi-instance task: one instance per item of a container-scoped variable, which the
/// interpreter reads back through <c>IBpmnVariableReader</c> while it evaluates.
/// </summary>
/// <remarks>
/// Three items rather than two, so the instance count cannot be confused with a declared cardinality. The
/// collection variable is declared on both sides — on the definition, because <c>BpmnGraph.Build</c> refuses a
/// loop naming a variable the process does not declare, and on the activity, because that is where the value
/// actually lives.
/// </remarks>
public static BpmnProcess CollectionMultiInstanceTask(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("collection-multi-instance-task")
.Variable(CollectionVariableName)
.StartEvent("start")
.Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, collectionVariable: CollectionVariableName))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "each", "after", "end")
.Build();
return Scope("scope", definition, [new Variable<string[]>(CollectionVariableName, ["alpha", "beta", "gamma"])], Immediate("each", log), Immediate("after", log));
}
/// <summary>
/// A transaction subprocess that cancels itself from the inside, and a cancel boundary event on the transaction
/// that routes the cancellation.
/// </summary>
/// <remarks>
/// The nested scope completes with the <c>Cancelled</c> outcome rather than <c>Done</c>, and the enclosing scope
/// only reaches the boundary path if that outcome survives the trip through the parent's completion callback.
/// Nothing else in the process distinguishes the two: with the outcome dropped the parent simply carries on down
/// the ordinary sequence flow, which is a completion that looks entirely successful.
/// </remarks>
public static BpmnProcess CancelledTransactionSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("transaction-body")
.Transaction()
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.EndEvent("subCancelled", null, Cancel())
.ConnectSequence("subStart", "subWork", "subCancelled")
.Build();
var definition = new BpmnProcessBuilder("cancelled-transaction-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel())
.Task("unwind", bindingRef: BindingRef("unwind"))
.EndEvent("unwound")
.ConnectSequence("start", "sub", "after", "end")
.ConnectSequence("cancelled", "unwind", "unwound")
.Build();
var nested = Scope("sub", body, Immediate("subWork", log));
return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
}
/// <summary>
/// Three bookings, each carrying a compensation boundary event, and a compensate end event that replays the lot.
/// </summary>
/// <remarks>
/// Three rather than two: with two, a handler order that merely happened to be reversed is indistinguishable from
/// one that swapped a pair, and a replay that walked the log forwards would still run every handler. The three
/// handlers bind work like anything else, and nothing in the graph flows into them — the only thing that can run
/// them is the replay.
/// </remarks>
public static BpmnProcess CompensatedBookings(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("compensated-bookings")
.StartEvent("start")
.Task("bookFlight", bindingRef: BindingRef("bookFlight"))
.Task("bookHotel", bindingRef: BindingRef("bookHotel"))
.Task("bookCar", bindingRef: BindingRef("bookCar"))
.EndEvent("undoEverything", null, Compensation())
.Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
.Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
.Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
.Element(CompensationHandler("undoFlight"))
.Element(CompensationHandler("undoHotel"))
.Element(CompensationHandler("undoCar"))
.ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoEverything")
.Build();
return Scope(
"scope",
definition,
Immediate("bookFlight", log),
Immediate("bookHotel", log),
Immediate("bookCar", log),
Immediate("undoFlight", log),
Immediate("undoHotel", log),
Immediate("undoCar", log));
}
/// <summary>
/// The same three bookings, compensated by an intermediate throw event naming one of them in its
/// <c>activityRef</c>, and a task after the throw that its outbound flow reaches once the replay is done.
/// </summary>
public static BpmnProcess TargetedCompensation(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("targeted-compensation")
.StartEvent("start")
.Task("bookFlight", bindingRef: BindingRef("bookFlight"))
.Task("bookHotel", bindingRef: BindingRef("bookHotel"))
.Task("bookCar", bindingRef: BindingRef("bookCar"))
.IntermediateThrowEvent("undoHotelOnly", Compensation(activityRef: "bookHotel"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
.Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
.Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
.Element(CompensationHandler("undoFlight"))
.Element(CompensationHandler("undoHotel"))
.Element(CompensationHandler("undoCar"))
.ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoHotelOnly", "after", "end")
.Build();
return Scope(
"scope",
definition,
Immediate("bookFlight", log),
Immediate("bookHotel", log),
Immediate("bookCar", log),
Immediate("undoFlight", log),
Immediate("undoHotel", log),
Immediate("undoCar", log),
Immediate("after", log));
}
/// <summary>
/// A transaction subprocess that starts a compensation replay on one branch and cancels itself on the other while
/// that replay is still running, so the replay's claimed-but-unrun log entries are torn down mid-run.
/// </summary>
/// <remarks>
/// <para>
/// The shape is what makes the release observable. <c>chargeCard</c> and <c>reserveSeat</c> both complete and
/// register, in that order, so the replay the intermediate throw opens claims both and runs them in reverse:
/// <c>releaseSeat</c> first, which blocks, leaving <c>refundCard</c> claimed and never started. Cancelling the
/// transaction from the other branch stops the replay's coordinating token, which is the only thing in scope that
/// tears a run down mid-flight.
/// </para>
/// <para>
/// <c>fraudCheck</c> blocks so a test decides when the cancellation happens, rather than racing the replay.
/// </para>
/// </remarks>
public static BpmnProcess CompensationRunCancelledMidReplay(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("mid-replay-cancel-body")
.Transaction()
.StartEvent("subStart")
.ParallelGateway("subSplit")
.Task("chargeCard", bindingRef: BindingRef("chargeCard"))
.Task("reserveSeat", bindingRef: BindingRef("reserveSeat"))
.IntermediateThrowEvent("rollBack", Compensation())
.EndEvent("rolledBack")
.Task("fraudCheck", bindingRef: BindingRef("fraudCheck"))
.EndEvent("subCancelled", null, Cancel())
.Element(CompensationBoundary("cardCompensated", attachedTo: "chargeCard", handler: "refundCard"))
.Element(CompensationBoundary("seatCompensated", attachedTo: "reserveSeat", handler: "releaseSeat"))
.Element(CompensationHandler("refundCard"))
.Element(CompensationHandler("releaseSeat"))
.ConnectSequence("subStart", "subSplit")
.Connect("subSplit", "chargeCard")
.ConnectSequence("chargeCard", "reserveSeat", "rollBack", "rolledBack")
.Connect("subSplit", "fraudCheck")
.ConnectSequence("fraudCheck", "subCancelled")
.Build();
var definition = new BpmnProcessBuilder("mid-replay-cancel")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel())
.Task("unwind", bindingRef: BindingRef("unwind"))
.EndEvent("unwound")
.ConnectSequence("start", "sub", "after", "end")
.ConnectSequence("cancelled", "unwind", "unwound")
.Build();
var nested = Scope(
"sub",
body,
Immediate("chargeCard", log),
Immediate("reserveSeat", log),
Blocking("fraudCheck", log),
Immediate("refundCard", log),
Blocking("releaseSeat", log));
return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
}
/// <summary>
/// A transaction subprocess that cancels itself from the inside, with nothing on the enclosing scope to route the
/// cancellation.
/// </summary>
/// <remarks>
/// The same shape as <see cref="CancelledTransactionSubprocess"/> minus the cancel boundary event. Graph
/// validation cannot see into the nested definition to know a cancel end event is in there, so an unroutable
/// cancellation is an execution-time rule: the enclosing scope faults rather than treating the transaction as an
/// ordinary completion and carrying on down the sequence flow.
/// </remarks>
public static BpmnProcess CancelledTransactionWithoutCancelBoundary(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("unroutable-cancel-body")
.Transaction()
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.EndEvent("subCancelled", null, Cancel())
.ConnectSequence("subStart", "subWork", "subCancelled")
.Build();
var definition = new BpmnProcessBuilder("unroutable-cancel")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope("sub", body, Immediate("subWork", log));
return Scope("scope", definition, nested, Immediate("after", log));
}
/// <summary>
/// A compensation log inside an embedded subprocess, and a second one in the enclosing scope that compensates the
/// subprocess itself.
/// </summary>
/// <remarks>
/// Two logs, one per scope, and neither can see the other: the body replays its own two handlers before it
/// completes, and the enclosing scope registers exactly one entry — the subprocess's own successful completion,
/// which its attached compensation boundary makes compensable — and replays that.
/// </remarks>
public static BpmnProcess CompensationInsideSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("compensating-subprocess-body")
.StartEvent("subStart")
.Task("subCharge", bindingRef: BindingRef("subCharge"))
.Task("subShip", bindingRef: BindingRef("subShip"))
.EndEvent("subUndo", null, Compensation())
.Element(CompensationBoundary("subChargeCompensated", attachedTo: "subCharge", handler: "subRefund"))
.Element(CompensationBoundary("subShipCompensated", attachedTo: "subShip", handler: "subRecall"))
.Element(CompensationHandler("subRefund"))
.Element(CompensationHandler("subRecall"))
.ConnectSequence("subStart", "subCharge", "subShip", "subUndo")
.Build();
var definition = new BpmnProcessBuilder("compensation-inside-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.EndEvent("undoOuter", null, Compensation())
.Element(CompensationBoundary("subCompensated", attachedTo: "sub", handler: "undoSub"))
.Element(CompensationHandler("undoSub"))
.ConnectSequence("start", "sub", "undoOuter")
.Build();
var nested = Scope(
"sub",
body,
Immediate("subCharge", log),
Immediate("subShip", log),
Immediate("subRefund", log),
Immediate("subRecall", log));
return Scope("scope", definition, nested, Immediate("undoSub", log));
}
/// <summary>
/// A task that fails, with a dormant error-triggered event subprocess in the same scope to catch it.
/// </summary>
/// <remarks>
/// An error event subprocess arms nothing: it rides the same <c>FaultSignal</c> seam an error boundary event does,
/// and the only thing that distinguishes it here is where the recovery work runs — inside a nested scope of its
/// own, seeded at the body's error start event, rather than on an outbound flow of the enclosing graph.
/// </remarks>
public static BpmnProcess ErrorEventSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("error-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error()))
.Task("handleError", bindingRef: BindingRef("handleError"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "errEnd")
.Build();
var definition = new BpmnProcessBuilder("error-event-subprocess")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.Element(EventSubprocess("evtSub"))
.ConnectSequence("start", "risky", "after", "end")
.Build();
return Scope("scope", definition, Faulting("risky", log), Immediate("after", log), Scope("evtSub", body, Immediate("handleError", log)));
}
/// <summary>
/// An escalation thrown out of an embedded subprocess, caught by a non-interrupting escalation-triggered event
/// subprocess on the enclosing scope rather than by a boundary event on the subprocess.
/// </summary>
/// <remarks>
/// Non-interrupting, so the escalating subprocess keeps running and nothing in the scope is torn down. That is
/// also what makes "the scope-level catcher fired" distinguishable from "the subprocess was stopped": with an
/// interrupting catcher the two are the same observation.
/// </remarks>
public static BpmnProcess EscalationEventSubprocessOutOfSubprocess(BpmnTestLog log)
{
var subBody = new BpmnProcessBuilder("escalating-subprocess-body")
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.IntermediateThrowEvent("subEscalate", Escalation("REVIEW"))
.Task("subMore", bindingRef: BindingRef("subMore"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subWork", "subEscalate", "subMore", "subEnd")
.Build();
var handlerBody = new BpmnProcessBuilder("escalation-event-subprocess-body")
.Element(EventSubprocessStart("escStart", Escalation("REVIEW"), interrupting: false))
.Task("handleEscalation", bindingRef: BindingRef("handleEscalation"))
.EndEvent("escEnd")
.ConnectSequence("escStart", "handleEscalation", "escEnd")
.Build();
var definition = new BpmnProcessBuilder("escalation-event-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.Element(EventSubprocess("evtSub"))
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope("sub", subBody, Blocking("subWork", log), Blocking("subMore", log));
return Scope("scope", definition, nested, Immediate("after", log), Scope("evtSub", handlerBody, Immediate("handleEscalation", log)));
}
/// <summary>
/// A non-interrupting message-triggered event subprocess: a listener armed at scope start, and a body that runs
/// each time the listener fires while the scope's own long-running work is still going.
/// </summary>
/// <remarks>
/// <para>
/// The listener is the second binding channel — <c>listenerBindingRef</c> — and is bound in the same
/// <c>WorkBindings</c> map as everything else. It stands in for a real message wait: blocking work a test
/// finishes, which is exactly what "the trigger fired" means to the host.
/// </para>
/// <para>
/// <c>work</c> blocks so the scope stays open across the fires and so that when it finally completes, the armed
/// listener is a <em>running</em> activity rather than a scheduled-but-not-yet-invoked one — the second of which
/// this host cannot withdraw at all.
/// </para>
/// </remarks>
public static BpmnProcess MessageEventSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("message-event-subprocess-body")
.Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
.Task("handleNudge", bindingRef: BindingRef("handleNudge"))
.EndEvent("msgEnd")
.ConnectSequence("msgStart", "handleNudge", "msgEnd")
.Build();
var definition = new BpmnProcessBuilder("message-event-subprocess")
.StartEvent("start")
.Task("work", bindingRef: BindingRef("work"))
.EndEvent("end")
.Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
.ConnectSequence("start", "work", "end")
.Build();
return Scope(
"scope",
definition,
Blocking("work", log),
Blocking("nudgeListener", log),
Scope("evtSub", body, Immediate("handleNudge", log)));
}
/// <summary>
/// The same message-triggered event subprocess, but inside an embedded subprocess that completes while the
/// enclosing scope carries on — so a listener that outlived the scope that armed it is distinguishable from one
/// that merely outlived the workflow.
/// </summary>
/// <remarks>
/// At the root, "the armed work does not survive the scope" and "does not survive the workflow" are the same
/// observation, and Elsa tears a finished workflow's children down regardless. Here the workflow keeps running
/// after the scope that armed the listener has completed, which is the only shape in which a listener left behind
/// is a listener something could still resume into.
/// </remarks>
public static BpmnProcess NestedMessageEventSubprocess(BpmnTestLog log)
{
var handlerBody = new BpmnProcessBuilder("nested-message-event-subprocess-body")
.Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
.Task("handleNudge", bindingRef: BindingRef("handleNudge"))
.EndEvent("msgEnd")
.ConnectSequence("msgStart", "handleNudge", "msgEnd")
.Build();
var subBody = new BpmnProcessBuilder("listening-subprocess-body")
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.EndEvent("subEnd")
.Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
.ConnectSequence("subStart", "subWork", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("nested-message-event-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope(
"sub",
subBody,
Blocking("subWork", log),
Blocking("nudgeListener", log),
Scope("evtSub", handlerBody, Immediate("handleNudge", log)));
return Scope("scope", definition, nested, Immediate("after", log));
}
/// <summary>
/// An error-triggered event subprocess whose body runs an ordinary embedded subprocess of its own, so the
/// start-element hint has both a place to arrive and a place it must not reach.
/// </summary>
/// <remarks>
/// <para>
/// The body's only start event is event-defined, which is what makes the hint's arrival observable rather than
/// merely asserted: seeded from the hint the body runs, and seeded as an ordinary direct invocation it faults
/// deterministically with <c>bpmn.start.none-available</c>, because there is no none start event to begin at.
/// </para>
/// <para>
/// The nested <c>inner</c> subprocess is the other direction. Its own invocation carries an ordinary scheduling
/// cause, so the hint must not be inherited: were it, the inner process would be seeded at an element it does not
/// declare and fault with <c>bpmn.start.unresolved-hint</c> instead of starting at its own none start event.
/// </para>
/// </remarks>
public static BpmnProcess EventSubprocessBodyWithNestedSubprocess(BpmnTestLog log)
{
var innerBody = new BpmnProcessBuilder("event-subprocess-inner-body")
.StartEvent("innerStart")
.Task("innerOnly", bindingRef: BindingRef("innerOnly"))
.EndEvent("innerEnd")
.ConnectSequence("innerStart", "innerOnly", "innerEnd")
.Build();
var body = new BpmnProcessBuilder("hinted-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error()))
.Task("handleError", bindingRef: BindingRef("handleError"))
.SubProcess("inner", bindingRef: BindingRef("inner"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "inner", "errEnd")
.Build();
var definition = new BpmnProcessBuilder("event-subprocess-start-hint")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.EndEvent("end")
.Element(EventSubprocess("evtSub"))
.ConnectSequence("start", "risky", "end")
.Build();
var handler = Scope("evtSub", body, Immediate("handleError", log), Scope("inner", innerBody, Immediate("innerOnly", log)));
return Scope("scope", definition, Faulting("risky", log), handler);
}
/// <summary>An event subprocess whose body declares two start events, which the library refuses.</summary>
public static BpmnProcess EventSubprocessBodyWithTwoStartEvents(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("two-start-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error()))
.StartEvent("alsoStart")
.Task("handleError", bindingRef: BindingRef("handleError"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "errEnd")
.Connect("alsoStart", "handleError")
.Build();
return RefusedEventSubprocessScope("two-start-events", log, ("evtSub", body, "handleError"));
}
/// <summary>Two error-triggered event subprocesses in one scope, which the library refuses.</summary>
public static BpmnProcess TwoErrorEventSubprocesses(BpmnTestLog log)
{
BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-error-event-subprocess-body")
.Element(EventSubprocessStart($"{prefix}Start", Error()))
.Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
.EndEvent($"{prefix}End")
.ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
.Build();
return RefusedEventSubprocessScope(
"two-error-event-subprocesses",
log,
("evtSubA", Body("first"), "firstHandle"),
("evtSubB", Body("second"), "secondHandle"));
}
/// <summary>Two code-less catch-all escalation-triggered event subprocesses in one scope, which the library refuses.</summary>
public static BpmnProcess TwoCatchAllEscalationEventSubprocesses(BpmnTestLog log)
{
BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-escalation-event-subprocess-body")
.Element(EventSubprocessStart($"{prefix}Start", Escalation(), interrupting: false))
.Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
.EndEvent($"{prefix}End")
.ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
.Build();
return RefusedEventSubprocessScope(
"two-catch-all-escalation-event-subprocesses",
log,
("evtSubA", Body("first"), "firstHandle"),
("evtSubB", Body("second"), "secondHandle"));
}
/// <summary>A non-interrupting error-triggered event subprocess, which is not legal BPMN and which the library refuses.</summary>
public static BpmnProcess NonInterruptingErrorEventSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("non-interrupting-error-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error(), interrupting: false))
.Task("handleError", bindingRef: BindingRef("handleError"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "errEnd")
.Build();
return RefusedEventSubprocessScope("non-interrupting-error-event-subprocess", log, ("evtSub", body, "handleError"));
}
/// <summary>
/// The <c>start/only/end</c> graph the refusal processes share, carrying the event subprocesses whose declaration
/// the library refuses. Nothing in it ever runs: the refusal is raised when the scope builds its graph, which is
/// before any work is started.
/// </summary>
private static BpmnProcess RefusedEventSubprocessScope(string processId, BpmnTestLog log, params (string ElementId, BpmnProcessDefinition Body, string HandlerId)[] eventSubprocesses)
{
var builder = new BpmnProcessBuilder(processId)
.StartEvent("start")
.Task("only", bindingRef: BindingRef("only"))
.EndEvent("end")
.ConnectSequence("start", "only", "end");
foreach (var eventSubprocess in eventSubprocesses)
builder = builder.Element(EventSubprocess(eventSubprocess.ElementId));
var work = new List<IActivity> { Immediate("only", log) };
work.AddRange(eventSubprocesses.Select(eventSubprocess => Scope(eventSubprocess.ElementId, eventSubprocess.Body, Immediate(eventSubprocess.HandlerId, log))));
return Scope("scope", builder.Build(), work.ToArray());
}
/// <summary>An embedded subprocess with one task in it, and one task after it in the enclosing scope.</summary>
public static BpmnProcess NestedSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("nested-subprocess-body")
.StartEvent("subStart")
.Task("subOnly", bindingRef: BindingRef("subOnly"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subOnly", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("nested-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
return Scope("scope", definition, Scope("sub", body, Immediate("subOnly", log)), Immediate("after", log));
}
/// <summary>
/// A parallel split and join, blocking on both branches, nested inside an embedded subprocess. Used to prove
/// a <em>nested</em> scope's own ledger -- not just a root scope's -- matches each completion back to its
/// binding after a round trip through Elsa's own serializer.
/// </summary>
public static BpmnProcess NestedParallelSplitAndJoinBlocking(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("nested-parallel-split-and-join-body")
.StartEvent("subStart")
.ParallelGateway("subSplit")
.Task("subLeft", bindingRef: BindingRef("subLeft"))
.Task("subRight", bindingRef: BindingRef("subRight"))
.ParallelGateway("subJoin")
.Task("subAfter", bindingRef: BindingRef("subAfter"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subSplit")
.Connect("subSplit", "subLeft")
.Connect("subSplit", "subRight")
.Connect("subLeft", "subJoin")
.Connect("subRight", "subJoin")
.ConnectSequence("subJoin", "subAfter", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("nested-parallel-split-and-join-blocking")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope("sub", body, Blocking("subLeft", log), Blocking("subRight", log), Immediate("subAfter", log));
return Scope("scope", definition, nested, Immediate("after", log));
}
/// <summary>A linear process: one task between a start and an end event.</summary>
public static BpmnProcess LinearTask(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("linear-task")
.StartEvent("start")
.Task("only", bindingRef: BindingRef("only"))
.EndEvent("end")
.ConnectSequence("start", "only", "end")
.Build();
return Scope("scope", definition, Immediate("only", log));
}
/// <summary>The binding ref the given element's work is declared under.</summary>
public static string BindingRef(string elementId) => $"node-{elementId}";
/// <summary>The name of the variable <see cref="CollectionMultiInstanceTask"/> loops over.</summary>
public const string CollectionVariableName = "items";
private static BpmnEventDefinition Timer() => new(BpmnEventDefinitionTypes.Timer);
private static BpmnEventDefinition Cancel() => new(BpmnEventDefinitionTypes.Cancel);