mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
331201294b
* Fix single-column value unwrap in declarative workflow * Added more tests
626 lines
24 KiB
C#
626 lines
24 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
|
using Microsoft.Agents.ObjectModel;
|
|
using Microsoft.PowerFx.Types;
|
|
|
|
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="ForeachExecutor"/>.
|
|
/// </summary>
|
|
public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
|
|
{
|
|
[Fact]
|
|
public void ForeachThrowsWhenModelInvalid() =>
|
|
// Arrange, Act & Assert
|
|
Assert.Throws<DeclarativeModelException>(() => new ForeachExecutor(new Foreach(), this.State));
|
|
|
|
[Fact]
|
|
public void ForeachNamingConvention()
|
|
{
|
|
// Arrange
|
|
string testId = this.CreateActionId().Value;
|
|
|
|
// Act
|
|
string startStep = ForeachExecutor.Steps.Start(testId);
|
|
string nextStep = ForeachExecutor.Steps.Next(testId);
|
|
string endStep = ForeachExecutor.Steps.End(testId);
|
|
|
|
// Assert
|
|
Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.Start)}", startStep);
|
|
Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.Next)}", nextStep);
|
|
Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.End)}", endStep);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachInvokedWithSingleValueAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue");
|
|
|
|
// Act & Assert
|
|
await this.ExecuteTestAsync(
|
|
displayName: nameof(ForeachInvokedWithSingleValueAsync),
|
|
items: ValueExpression.Literal(new NumberDataValue(42)),
|
|
valueName: "CurrentValue",
|
|
indexName: null);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachInvokedWithTableValueAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue");
|
|
|
|
// Act & Assert
|
|
await this.ExecuteTestAsync(
|
|
displayName: nameof(ForeachInvokedWithTableValueAsync),
|
|
items: ValueExpression.Literal(DataValue.EmptyTable),
|
|
valueName: "CurrentValue",
|
|
indexName: null);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachInvokedWithIndexAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue", "CurrentIndex");
|
|
TableDataValue tableValue = DataValue.TableFromRecords(
|
|
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("item", new NumberDataValue(1))),
|
|
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("item", new NumberDataValue(2))),
|
|
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("item", new NumberDataValue(3))));
|
|
|
|
// Act & Assert
|
|
await this.ExecuteTestAsync(
|
|
displayName: nameof(ForeachInvokedWithIndexAsync),
|
|
items: ValueExpression.Literal(tableValue),
|
|
valueName: "CurrentValue",
|
|
indexName: "CurrentIndex");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachInvokedWithExpressionAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue");
|
|
this.State.Set("SourceArray", FormulaValue.NewTable(RecordType.Empty()));
|
|
|
|
// Act & Assert
|
|
await this.ExecuteTestAsync(
|
|
displayName: nameof(ForeachInvokedWithExpressionAsync),
|
|
items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")),
|
|
valueName: "CurrentValue",
|
|
indexName: null);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachTakeNextAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue");
|
|
this.State.Set(
|
|
"SourceArray",
|
|
FormulaValue.NewTable(
|
|
RecordType.Empty(),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30)))));
|
|
|
|
// Act & Assert
|
|
await this.TakeNextTestAsync(
|
|
displayName: nameof(ForeachTakeNextAsync),
|
|
items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")),
|
|
valueName: "CurrentValue",
|
|
indexName: null);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachTakeNextWithIndexAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue", "CurrentIndex");
|
|
this.State.Set(
|
|
"SourceArray",
|
|
FormulaValue.NewTable(
|
|
RecordType.Empty(),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30)))));
|
|
|
|
// Act & Assert
|
|
await this.TakeNextTestAsync(
|
|
displayName: nameof(ForeachTakeNextWithIndexAsync),
|
|
items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")),
|
|
valueName: "CurrentValue",
|
|
indexName: "CurrentIndex");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachTakeNextWithMultiFieldRecordAsync()
|
|
{
|
|
// Arrange
|
|
const string CurrentValueName = "CurrentValue";
|
|
this.SetVariableState(CurrentValueName);
|
|
|
|
TableDataValue tableValue = DataValue.TableFromRecords(
|
|
DataValue.RecordFromFields(
|
|
new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice")),
|
|
new KeyValuePair<string, DataValue>("role", new StringDataValue("Engineer"))));
|
|
|
|
Foreach model = this.CreateModel(
|
|
displayName: nameof(ForeachTakeNextWithMultiFieldRecordAsync),
|
|
items: ValueExpression.Literal(tableValue),
|
|
valueName: CurrentValueName,
|
|
indexName: null);
|
|
ForeachExecutor action = new(model, this.State);
|
|
|
|
// Act
|
|
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
|
|
|
// Assert
|
|
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
|
|
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
|
|
Assert.Equal("Engineer", currentValue.GetField("role").ToObject());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Power Fx wraps scalar array literals such as <c>=[1, 2, 3]</c> as <c>Table({Value: 1}, ...)</c>;
|
|
/// the loop value must expose the bare scalar, not the single-column wrapper record.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ForeachTakeNextWithSingleColumnValueRecordAsync()
|
|
{
|
|
// Arrange
|
|
const string CurrentValueName = "CurrentValue";
|
|
this.SetVariableState(CurrentValueName);
|
|
|
|
TableDataValue tableValue = DataValue.TableFromRecords(
|
|
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(1))),
|
|
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(2))),
|
|
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(3))));
|
|
|
|
Foreach model = this.CreateModel(
|
|
displayName: nameof(ForeachTakeNextWithSingleColumnValueRecordAsync),
|
|
items: ValueExpression.Literal(tableValue),
|
|
valueName: CurrentValueName,
|
|
indexName: null);
|
|
ForeachExecutor action = new(model, this.State);
|
|
|
|
// Act
|
|
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
|
|
|
// Assert
|
|
FormulaValue currentValue = this.State.Get(CurrentValueName);
|
|
Assert.IsNotType<RecordValue>(currentValue, exactMatch: false);
|
|
Assert.Equal(1m, currentValue.ToObject());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Single-field records whose only field is NOT named <c>Value</c> are not Power Fx auto-wraps;
|
|
/// they are preserved as records so the field name remains accessible inside the loop body.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ForeachTakeNextWithSingleFieldNonValueRecordAsync()
|
|
{
|
|
// Arrange
|
|
const string CurrentValueName = "CurrentValue";
|
|
this.SetVariableState(CurrentValueName);
|
|
|
|
TableDataValue tableValue = DataValue.TableFromRecords(
|
|
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice"))));
|
|
|
|
Foreach model = this.CreateModel(
|
|
displayName: nameof(ForeachTakeNextWithSingleFieldNonValueRecordAsync),
|
|
items: ValueExpression.Literal(tableValue),
|
|
valueName: CurrentValueName,
|
|
indexName: null);
|
|
ForeachExecutor action = new(model, this.State);
|
|
|
|
// Act
|
|
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
|
|
|
// Assert
|
|
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
|
|
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachTakeLastAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue");
|
|
this.State.Set(
|
|
"SourceArray",
|
|
FormulaValue.NewTable(
|
|
RecordType.Empty(),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10)))));
|
|
|
|
// Act & Assert
|
|
await this.TakeNextTestAsync(
|
|
displayName: nameof(ForeachTakeLastAsync),
|
|
items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")),
|
|
valueName: "CurrentValue",
|
|
indexName: null);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachTakeNextWhenDoneAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue");
|
|
|
|
// Act & Assert
|
|
await this.TakeNextTestAsync(
|
|
displayName: nameof(ForeachTakeNextWhenDoneAsync),
|
|
items: ValueExpression.Literal(DataValue.EmptyTable),
|
|
valueName: "CurrentValue",
|
|
indexName: null,
|
|
expectValue: false);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachCompletedWithoutIndexAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue");
|
|
|
|
// Act & Assert
|
|
await this.CompletedTestAsync(
|
|
displayName: nameof(ForeachCompletedWithoutIndexAsync),
|
|
valueName: "CurrentValue",
|
|
indexName: null);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeachCompletedWithIndexAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue", "CurrentIndex");
|
|
|
|
// Act & Assert
|
|
await this.CompletedTestAsync(
|
|
displayName: nameof(ForeachCompletedWithIndexAsync),
|
|
valueName: "CurrentValue",
|
|
indexName: "CurrentIndex");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression test for GH-5009: a <see cref="ForeachExecutor"/> that is re-instantiated
|
|
/// during checkpoint restore (e.g. cross-process resume after a <c>Question</c> inside the
|
|
/// loop body) must continue iterating from where it left off, not exit after the first
|
|
/// iteration.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ForeachStateRestoredAcrossCheckpointAsync()
|
|
{
|
|
// Arrange — a 3-item source table and a freshly-bound foreach executor (instance A).
|
|
const string SourceVariableName = "SourceArray";
|
|
this.SetVariableState("CurrentValue");
|
|
this.State.Set(
|
|
SourceVariableName,
|
|
FormulaValue.NewTable(
|
|
RecordType.Empty(),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))),
|
|
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30)))));
|
|
|
|
Foreach model = this.CreateModel(
|
|
displayName: nameof(ForeachStateRestoredAcrossCheckpointAsync),
|
|
items: ValueExpression.Variable(PropertyPath.TopicVariable(SourceVariableName)),
|
|
valueName: "CurrentValue",
|
|
indexName: null);
|
|
|
|
ForeachExecutor instanceA = new(model, this.State);
|
|
|
|
// Drive instance A through ExecuteAsync (initializes _values/_index) and one TakeNextAsync
|
|
// so that _index advances to 1 and HasValue is true — the state at the point a Question
|
|
// inside the loop body would pause the workflow and trigger a checkpoint.
|
|
await this.ExecuteAsync(instanceA, ForeachExecutor.Steps.Next(instanceA.Id), instanceA.TakeNextAsync);
|
|
Assert.True(instanceA.HasValue, "Instance A should have a current item after the first TakeNextAsync.");
|
|
|
|
// Act 1 — instance A persists checkpoint state.
|
|
InMemoryWorkflowContext checkpoint = new();
|
|
await InvokeOnCheckpointingAsync(instanceA, checkpoint);
|
|
|
|
// Act 2 — a fresh instance B (simulating cross-process resume) restores from the checkpoint.
|
|
ForeachExecutor instanceB = new(model, this.State);
|
|
await InvokeOnCheckpointRestoredAsync(instanceB, checkpoint);
|
|
|
|
// Assert — HasValue carries over so the routing predicate after loopId continues to take
|
|
// the "loop body" edge instead of falling through to the loop continuation.
|
|
Assert.True(instanceB.HasValue, "Restored instance should report HasValue == true at the checkpointed cursor.");
|
|
|
|
// Drive iteration 2 and 3 through instance B; both should succeed.
|
|
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
|
|
Assert.True(instanceB.HasValue, "Restored instance should advance to iteration 2 (value=20).");
|
|
|
|
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
|
|
Assert.True(instanceB.HasValue, "Restored instance should advance to iteration 3 (value=30).");
|
|
|
|
// Driving past the end exits the loop normally.
|
|
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
|
|
Assert.False(instanceB.HasValue, "Restored instance should report HasValue == false after exhausting all items.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// When no checkpoint state has been written for the executor (e.g. first run), the restore
|
|
/// hook must be a no-op and leave constructor defaults in place.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ForeachRestoreWithNoSavedStateAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue");
|
|
Foreach model = this.CreateModel(
|
|
displayName: nameof(ForeachRestoreWithNoSavedStateAsync),
|
|
items: ValueExpression.Literal(DataValue.EmptyTable),
|
|
valueName: "CurrentValue",
|
|
indexName: null);
|
|
|
|
ForeachExecutor executor = new(model, this.State);
|
|
InMemoryWorkflowContext emptyContext = new();
|
|
|
|
// Act — restoring against an empty context must not throw and must leave the executor
|
|
// in its constructor-default state.
|
|
await InvokeOnCheckpointRestoredAsync(executor, emptyContext);
|
|
|
|
// Assert
|
|
Assert.False(executor.HasValue);
|
|
|
|
// A subsequent TakeNextAsync (without a prior ExecuteAsync) should report no value
|
|
// because _values is still the empty constructor default.
|
|
await executor.TakeNextAsync(emptyContext, _: null, CancellationToken.None);
|
|
Assert.False(executor.HasValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checkpoint/restore around a foreach over an empty source must roundtrip cleanly
|
|
/// (zero-length <c>PortableValue[]</c> snapshot).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ForeachStateSurvivesEmptyValuesAsync()
|
|
{
|
|
// Arrange
|
|
this.SetVariableState("CurrentValue");
|
|
Foreach model = this.CreateModel(
|
|
displayName: nameof(ForeachStateSurvivesEmptyValuesAsync),
|
|
items: ValueExpression.Literal(DataValue.EmptyTable),
|
|
valueName: "CurrentValue",
|
|
indexName: null);
|
|
|
|
ForeachExecutor instanceA = new(model, this.State);
|
|
|
|
// Run ExecuteAsync (which sets _values = []) followed by one TakeNextAsync (which sets
|
|
// HasValue = false on an empty source).
|
|
await this.ExecuteAsync(instanceA, ForeachExecutor.Steps.Next(instanceA.Id), instanceA.TakeNextAsync);
|
|
Assert.False(instanceA.HasValue);
|
|
|
|
// Act — checkpoint and restore into a fresh instance.
|
|
InMemoryWorkflowContext checkpoint = new();
|
|
await InvokeOnCheckpointingAsync(instanceA, checkpoint);
|
|
|
|
ForeachExecutor instanceB = new(model, this.State);
|
|
await InvokeOnCheckpointRestoredAsync(instanceB, checkpoint);
|
|
|
|
// Assert — restored instance must agree that the source is empty and HasValue is false.
|
|
Assert.False(instanceB.HasValue);
|
|
|
|
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
|
|
Assert.False(instanceB.HasValue);
|
|
}
|
|
|
|
private void SetVariableState(string valueName, string? indexName = null, FormulaValue? valueState = null)
|
|
{
|
|
this.State.Set(valueName, valueState ?? FormulaValue.New("something"));
|
|
if (indexName is not null)
|
|
{
|
|
this.State.Set(indexName, FormulaValue.New(33));
|
|
}
|
|
}
|
|
|
|
private async Task ExecuteTestAsync(
|
|
string displayName,
|
|
ValueExpression items,
|
|
string valueName,
|
|
string? indexName,
|
|
bool expectValue = false)
|
|
{
|
|
// Arrange
|
|
Foreach model = this.CreateModel(displayName, items, valueName, indexName);
|
|
ForeachExecutor action = new(model, this.State);
|
|
|
|
// Act
|
|
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
|
|
|
// Assert
|
|
VerifyModel(model, action);
|
|
VerifyInvocationEvent(events);
|
|
|
|
// IsDiscreteAction should be false for Foreach
|
|
VerifyIsDiscrete(action, isDiscrete: false);
|
|
|
|
// Verify HasValue state after execution
|
|
Assert.Equal(expectValue, action.HasValue);
|
|
|
|
// Verify value was reset at the end
|
|
this.VerifyUndefined(valueName);
|
|
|
|
// Verify index was reset at the end if it was used
|
|
if (indexName is not null)
|
|
{
|
|
this.VerifyUndefined(indexName);
|
|
}
|
|
}
|
|
|
|
private async Task TakeNextTestAsync(
|
|
string displayName,
|
|
ValueExpression items,
|
|
string valueName,
|
|
string? indexName,
|
|
bool expectValue = true)
|
|
{
|
|
// Arrange
|
|
Foreach model = this.CreateModel(displayName, items, valueName, indexName);
|
|
ForeachExecutor action = new(model, this.State);
|
|
|
|
// Act
|
|
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
|
|
|
// Assert
|
|
VerifyModel(model, action);
|
|
|
|
// Verify HasValue state after execution
|
|
Assert.Equal(expectValue, action.HasValue);
|
|
}
|
|
|
|
private async Task CompletedTestAsync(
|
|
string displayName,
|
|
string valueName,
|
|
string? indexName)
|
|
{
|
|
// Arrange
|
|
Foreach model = this.CreateModel(displayName, ValueExpression.Literal(DataValue.EmptyTable), valueName, indexName);
|
|
ForeachExecutor action = new(model, this.State);
|
|
|
|
// Act
|
|
WorkflowEvent[] events = await this.ExecuteAsync(ForeachExecutor.Steps.End(action.Id), action.CompleteAsync);
|
|
|
|
// Assert
|
|
VerifyModel(model, action);
|
|
VerifyCompletionEvent(events);
|
|
|
|
// Verify HasValue state after completion
|
|
Assert.False(action.HasValue);
|
|
|
|
// Verify value was reset at the end
|
|
this.VerifyUndefined(valueName);
|
|
|
|
// Verify index was reset at the end if it was used
|
|
if (indexName is not null)
|
|
{
|
|
this.VerifyUndefined(indexName);
|
|
}
|
|
}
|
|
|
|
private Foreach CreateModel(
|
|
string displayName,
|
|
ValueExpression items,
|
|
string valueName,
|
|
string? indexName)
|
|
{
|
|
Foreach.Builder actionBuilder = new()
|
|
{
|
|
Id = this.CreateActionId(),
|
|
DisplayName = this.FormatDisplayName(displayName),
|
|
Items = items,
|
|
Value = PropertyPath.Create(FormatVariablePath(valueName)),
|
|
};
|
|
|
|
if (indexName is not null)
|
|
{
|
|
actionBuilder.Index = PropertyPath.Create(FormatVariablePath(indexName));
|
|
}
|
|
|
|
return AssignParent<Foreach>(actionBuilder);
|
|
}
|
|
|
|
// Reflection helpers used to invoke the `protected internal` checkpoint hooks on the executor
|
|
// base class from this test project (which is in a different assembly than Microsoft.Agents.AI.Workflows
|
|
// and is not granted InternalsVisibleTo there).
|
|
private static Task InvokeOnCheckpointingAsync(Executor executor, IWorkflowContext context) =>
|
|
InvokeProtectedCheckpointHookAsync(executor, context, methodName: "OnCheckpointingAsync");
|
|
|
|
private static Task InvokeOnCheckpointRestoredAsync(Executor executor, IWorkflowContext context) =>
|
|
InvokeProtectedCheckpointHookAsync(executor, context, methodName: "OnCheckpointRestoredAsync");
|
|
|
|
private static async Task InvokeProtectedCheckpointHookAsync(Executor executor, IWorkflowContext context, string methodName)
|
|
{
|
|
MethodInfo method = typeof(Executor).GetMethod(
|
|
methodName,
|
|
BindingFlags.Instance | BindingFlags.NonPublic,
|
|
binder: null,
|
|
types: new[] { typeof(IWorkflowContext), typeof(CancellationToken) },
|
|
modifiers: null) ?? throw new InvalidOperationException($"Could not locate {methodName} on Executor.");
|
|
|
|
ValueTask invocation = (ValueTask)method.Invoke(executor, new object[] { context, CancellationToken.None })!;
|
|
await invocation;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal in-memory <see cref="IWorkflowContext"/> implementation used to drive the
|
|
/// checkpoint/restore overrides on <see cref="ForeachExecutor"/> directly from a unit test.
|
|
/// Records state writes in a (scope, key) dictionary and serves matching reads back. Only the
|
|
/// state-related members are exercised by the checkpoint hooks; the other members are stubbed.
|
|
/// </summary>
|
|
private sealed class InMemoryWorkflowContext : IWorkflowContext
|
|
{
|
|
private readonly Dictionary<(string? scope, string key), object?> _store = [];
|
|
|
|
public bool ConcurrentRunsEnabled => false;
|
|
|
|
public IReadOnlyDictionary<string, string>? TraceContext => null;
|
|
|
|
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
|
|
{
|
|
this._store[(scopeName, key)] = value;
|
|
return default;
|
|
}
|
|
|
|
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (this._store.TryGetValue((scopeName, key), out object? stored) && stored is T typed)
|
|
{
|
|
return new ValueTask<T?>(typed);
|
|
}
|
|
|
|
return new ValueTask<T?>(default(T));
|
|
}
|
|
|
|
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (this._store.TryGetValue((scopeName, key), out object? stored) && stored is T typed)
|
|
{
|
|
return new ValueTask<T>(typed);
|
|
}
|
|
|
|
T initial = initialStateFactory();
|
|
this._store[(scopeName, key)] = initial;
|
|
return new ValueTask<T>(initial);
|
|
}
|
|
|
|
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
|
{
|
|
HashSet<string> keys = new(
|
|
this._store.Keys
|
|
.Where(slot => string.Equals(slot.scope, scopeName, StringComparison.Ordinal))
|
|
.Select(slot => slot.key));
|
|
return new ValueTask<HashSet<string>>(keys);
|
|
}
|
|
|
|
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
|
{
|
|
foreach ((string? scope, string key) slot in this._store.Keys.Where(slot => string.Equals(slot.scope, scopeName, StringComparison.Ordinal)).ToArray())
|
|
{
|
|
this._store.Remove(slot);
|
|
}
|
|
|
|
return default;
|
|
}
|
|
|
|
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => default;
|
|
|
|
public ValueTask SendMessageAsync(object message, string? targetId, CancellationToken cancellationToken = default) => default;
|
|
|
|
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) => default;
|
|
|
|
public ValueTask RequestHaltAsync() => default;
|
|
}
|
|
}
|