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; /// /// 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. /// 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("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("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("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("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("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("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("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); } /// /// A reader over a scope declaring the given variables, exactly as Container.ExecuteAsync declares them /// before scheduling anything. /// private static async Task 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; } } }