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; }
}
}