* feat(bpmn): host-side applier for the Bpmn.Semantics port Translates the interpreter's three host commands onto ActivityExecutionContext and feeds its four entry points, plus the minimum BpmnProcess container needed to exercise them end to end through IWorkflowRunner. StartWork schedules the bound activity, CancelWorkSubtree calls the public CancelActivityAsync extension (already recursive), and SignalEnclosingScope sends a BpmnScopeSignal up the ancestor chain. OnWorkFaulted rides the FaultSignal seam: it asks the interpreter what BPMN made of the fault and calls StopPropagation only on a Caught disposition, leaving a Propagated one strictly alone so an enclosing scope or the incident strategy takes it. A unit of work is keyed on the child ActivityExecutionContext.Id, recorded in the scope's own persisted ledger, never on Tag: the completion-callback dispatch rewrites the receiving context's Tag, so a nested scope wears a different tag than its parent remembers it by. Interpreter correlation travels on the child's context rather than on the shared activity instance. Evaluations go through one queue per workflow instance, so a scope signalled mid-apply is drained after the command list rather than re-entering the interpreter. Commands are applied in the order returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): cover the teardown refusal path Adds a focused unit test that drives BpmnWorkTeardown.CancelSubtreeAsync into the NotSupportedException branch by constructing a real context tree with a scheduled-but-not-invoked descendant, so a regression that silently drops the detection is caught. Also records why BpmnWorkLedger's append-only, handle-keyed Records list cannot strand a context on a duplicate StartWork for a live (BindingRef, IterationId) slot, a case the port's own guarantee makes unreachable from this applier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(bpmn): keep a refused teardown from stranding ledger state A subtree cancellation refused with NotSupportedException is absorbed into an incident under ContinueWithIncidentsStrategy rather than crashing, so the end-of-command ledger save was being skipped and the persisted ledger kept claiming work BPMN had just torn down. Save the ledger removal before the possible throw instead of after, so a later completion callback for the stranded activity finds no live record and is discarded instead of being fed to the interpreter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
68 lines
2.2 KiB
C#
68 lines
2.2 KiB
C#
using Elsa.Bpmn.Hosting;
|
|
|
|
namespace Elsa.Bpmn.UnitTests;
|
|
|
|
/// <summary>
|
|
/// The queue that keeps a scope evaluation raised while another one is applying its commands from running on top of it.
|
|
/// </summary>
|
|
public class BpmnScopeDispatcherTests
|
|
{
|
|
private readonly BpmnScopeDispatcher _dispatcher = new();
|
|
private readonly List<string> _log = [];
|
|
|
|
[Fact(DisplayName = "An evaluation posted while one is in flight runs after it, not inside it")]
|
|
public async Task PostFromInsideARunningEvaluation_IsQueued()
|
|
{
|
|
await _dispatcher.PostAsync(async () =>
|
|
{
|
|
_log.Add("outer:begin");
|
|
await _dispatcher.PostAsync(() =>
|
|
{
|
|
_log.Add("inner");
|
|
return default;
|
|
});
|
|
_log.Add("outer:end");
|
|
});
|
|
|
|
Assert.Equal(["outer:begin", "outer:end", "inner"], _log);
|
|
}
|
|
|
|
[Fact(DisplayName = "Evaluations run in the order they were posted")]
|
|
public async Task QueuedEvaluations_RunInPostOrder()
|
|
{
|
|
await _dispatcher.PostAsync(async () =>
|
|
{
|
|
await _dispatcher.PostAsync(() => Add("first"));
|
|
await _dispatcher.PostAsync(() => Add("second"));
|
|
await Add("root");
|
|
});
|
|
|
|
Assert.Equal(["root", "first", "second"], _log);
|
|
}
|
|
|
|
[Fact(DisplayName = "An evaluation that throws drops what was queued behind it")]
|
|
public async Task FailedEvaluation_DropsTheRest()
|
|
{
|
|
// Everything queued behind a failed evaluation was planned against a state that no longer describes the
|
|
// instance, and the failure is on its way to the incident strategy.
|
|
await Assert.ThrowsAsync<InvalidOperationException>(async () => await _dispatcher.PostAsync(async () =>
|
|
{
|
|
await _dispatcher.PostAsync(() => Add("queued"));
|
|
throw new InvalidOperationException("boom");
|
|
}));
|
|
|
|
Assert.Empty(_log);
|
|
Assert.False(_dispatcher.IsDraining);
|
|
|
|
// The queue is usable again, and does not resurrect what was dropped.
|
|
await _dispatcher.PostAsync(() => Add("after"));
|
|
Assert.Equal(["after"], _log);
|
|
}
|
|
|
|
private ValueTask Add(string entry)
|
|
{
|
|
_log.Add(entry);
|
|
return default;
|
|
}
|
|
}
|