elsa-core/test/unit/Elsa.Bpmn.UnitTests/BpmnScopeVariablesTests.cs
Sipke Schoorstra 65fe688350
feat(bpmn): the BpmnProcess container activity (#7945)
* feat(bpmn): scope variables, trigger opt-out and composability for BpmnProcess

Completes the container W2 left minimal, with the four things it deferred.

Scope variables. BpmnScopeVariables implements IBpmnVariableReader over the
scope's memory register, walking outward so an inner scope sees the enclosing
one's data, and BpmnScopeHost now declares ScopeVariables and hands the reader
to every snapshot. The read is three-valued: false for a name nothing in scope
declares, Null for a declared variable holding nothing, and StoredExternally
for a value JSON cannot carry.

That last case deviates from the issue, deliberately. The issue names the
unmaterialized-driver case, which is not detectable from the container's side:
PersistentVariablesMiddleware loads with no excludeTags, and
VariablePersistenceManager marks a block IsInitialized before testing the
exclusion, so a variable whose driver was never read is indistinguishable from
one whose driver returned null. Closing that needs a change to
Elsa.Workflows.Core, which is out of bounds here, so the reader answers only
what the block actually says and the XML doc records why. The route it does
have is real and in the same spirit: a value the host holds and cannot put on
the wire faults loudly rather than reading as an empty collection.

Trigger opt-out. BpmnProcess.IsRootScope names the BPMN meaning of Elsa's
CanStartWorkflow rather than adding a second flag that could disagree with the
gate TriggerIndexer actually reads. It is off unless something says otherwise,
and the applier refuses to start a BpmnProcess that claims root position as
another scope's work: the damage a mis-flagged subprocess does happens at
publish time, so repairing the object graph at runtime would leave the trigger
registered while every test went green. ITrigger itself remains #7929.

Composability and outcomes. A BpmnProcess in a Flowchart runs and the flowchart
carries on (D11), and a nested transaction completing Cancelled reaches its
parent's completion callback with that outcome intact, which is the only reason
the parent routes the cancel boundary rather than the ordinary sequence flow.

Every guard was mutation-tested red before green: both non-Present answers of
the reader, the reader left unwired, the opt-out's default flipped (7 tests red,
including the pre-existing nested-scope ones), the refusal removed, and the
outcome dropped at each end of the trip to the parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): apply review findings on scope variables, command batching, and outcome doc

Read a scope variable through Elsa's configured serializer (via IPayloadSerializer,
serialized against the value's own runtime type so a polymorphic value is not wrapped
in Elsa's type-tagged envelope) instead of bare JsonSerializerDefaults, so a value only
Elsa's converters can carry no longer collapses to StoredExternally. Refuse a root-scope
StartWork before any command in the batch is applied, not mid-list, so a refusal cannot
leave scope memory partially mutated under ContinueWithIncidentsStrategy. Document that
BpmnProcess completes with only its interpreter outcome, so a default/null-port
Flowchart connection never fires from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bpmn): filter the pre-scan explicitly

Use commands.OfType<BpmnHostCommand.StartWork>() in ApplyAsync's
root-scope pre-scan instead of a foreach + type-check, matching the
static analysis suggestion. The apply loop below is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:37:35 +02:00

128 lines
5.8 KiB
C#

using System.Text.Json;
using Bpmn.Model;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.Hosting;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Memory;
namespace Elsa.Bpmn.UnitTests;
/// <summary>
/// The one callback the interpreter makes into the host, and the three answers it distinguishes. The third —
/// "I have it and cannot give it to you" — is the one worth having: reported as null or absent instead, a
/// collection-mode multi-instance resolves to zero instances and the process completes as though there had been
/// nothing to do.
/// </summary>
public class BpmnScopeVariablesTests
{
[Fact(DisplayName = "A variable nothing in scope declares reads as absent")]
public async Task TryRead_ReturnsFalse_ForAnUndeclaredVariable()
{
var variables = await ReaderForAsync(new Variable<string>("declared", "value"));
Assert.False(variables.TryRead("undeclared", out var value));
Assert.Equal(BpmnValuePresence.Absent, value.Presence);
}
[Fact(DisplayName = "A declared variable holding null reads as present-and-null, not as absent")]
public async Task TryRead_ReturnsNull_ForADeclaredVariableHoldingNull()
{
var variables = await ReaderForAsync(new Variable<string?>("empty", null));
Assert.True(variables.TryRead("empty", out var value));
Assert.Equal(BpmnValuePresence.Null, value.Presence);
}
[Fact(DisplayName = "A declared variable holding a value reads as present, inline")]
public async Task TryRead_ReturnsThePayload_ForADeclaredVariableHoldingAValue()
{
var variables = await ReaderForAsync(new Variable<string[]>("items", ["alpha", "beta"]));
Assert.True(variables.TryRead("items", out var value));
Assert.Equal(BpmnValuePresence.Present, value.Presence);
Assert.True(value.HasValue);
Assert.Equal(JsonValueKind.Array, value.Json!.Value.ValueKind);
Assert.Equal(["alpha", "beta"], value.Json.Value.EnumerateArray().Select(item => item.GetString()));
}
[Fact(DisplayName = "An integer carries the type hint the interpreter understands")]
public async Task TryRead_HintsInteger_ForAWholeNumber()
{
var variables = await ReaderForAsync(new Variable<int>("count", 3));
Assert.True(variables.TryRead("count", out var value));
Assert.Equal(BpmnValueTypes.Integer, value.TypeHint);
}
[Fact(DisplayName = "A value that cannot cross the port inline reads as stored externally, not as null")]
public async Task TryRead_ReturnsStoredExternally_ForAValueJsonCannotCarry()
{
// A cyclic object graph is the everyday version of this: a node holding its parent. The variable is neither
// missing nor null, and saying either would be a quiet wrong answer — the interpreter treats
// StoredExternally as unreadable and faults, naming the element that asked.
var cyclic = new SelfReferencing();
cyclic.Self = cyclic;
var variables = await ReaderForAsync(new Variable<SelfReferencing>("cyclic", cyclic));
Assert.True(variables.TryRead("cyclic", out var value));
Assert.Equal(BpmnValuePresence.StoredExternally, value.Presence);
Assert.False(value.HasValue);
}
[Fact(DisplayName = "A value bare JsonSerializerDefaults cannot serialize, but Elsa's configured serializer can, reads as present")]
public async Task TryRead_ReturnsThePayload_ForAValueOnlyElsasSerializerCanCarry()
{
// System.Text.Json refuses to serialize a System.Type instance under bare defaults — it throws
// NotSupportedException. Elsa's configured serializer carries it via TypeJsonConverter. A reader using bare
// defaults collapses this to StoredExternally even though Elsa can hand the value over intact.
var variables = await ReaderForAsync(new Variable<Type>("clrType", typeof(string)));
Assert.True(variables.TryRead("clrType", out var value));
Assert.Equal(BpmnValuePresence.Present, value.Presence);
Assert.True(value.HasValue);
}
[Fact(DisplayName = "A variable declared by an enclosing scope is visible to the scope inside it")]
public async Task TryRead_WalksOutward_ForAVariableOfAnEnclosingScope()
{
// BPMN data scoping and Elsa's agree: an inner scope sees the enclosing scope's variables.
var outerVariable = new Variable<string>("outer", "value");
var inner = new BpmnProcess { Id = "inner" };
var outer = new BpmnProcess { Id = "outer", Variables = { outerVariable }, Activities = { inner } };
var outerContext = await new ActivityTestFixture(outer).BuildAsync();
outerContext.ExpressionExecutionContext.Memory.Declare(outer.Variables);
var workflowExecutionContext = outerContext.WorkflowExecutionContext;
await workflowExecutionContext.ActivityRegistry.RegisterAsync(typeof(BpmnProcess));
var innerContext = await workflowExecutionContext.CreateActivityExecutionContextAsync(inner, new() { Owner = outerContext });
Assert.True(new BpmnScopeVariables(innerContext).TryRead("outer", out var value));
Assert.Equal(BpmnValuePresence.Present, value.Presence);
}
/// <summary>
/// A reader over a scope declaring the given variables, exactly as <c>Container.ExecuteAsync</c> declares them
/// before scheduling anything.
/// </summary>
private static async Task<BpmnScopeVariables> ReaderForAsync(params Variable[] variables)
{
var process = new BpmnProcess { Id = "scope" };
foreach (var variable in variables)
process.Variables.Add(variable);
var context = await new ActivityTestFixture(process).BuildAsync();
context.ExpressionExecutionContext.Memory.Declare(process.Variables);
return new(context);
}
private sealed class SelfReferencing
{
public SelfReferencing? Self { get; set; }
}
}