Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions docs/stateful/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,98 @@ public override CounterState Update(CounterState state, bool input, Var<int> out

The `outputVar` is a symbolic reference to this command's output. Later commands can resolve it to get the actual value.

#### Projecting Fields from Structured Outputs

When a command returns a structured type (like a record or class), you often want to store individual fields in your state rather than the entire object. Use `Var.map` (F#) or `.Select()` (C#) to project fields from the output:

# [F#](#tab/fsharp)

```fsharp
// Command that returns a structured Person type
type Person = {
Name: string
Age: int
}

type RegistryState = {
LastPersonName: Var<string>
LastPersonAge: Var<int>
}

type AddPersonCommand() =
inherit Command<PersonRegistry, RegistryState, string * int, Person>()

override _.Execute(sut, env, state, (name, age)) =
let person = sut.AddPerson(name, age)
Task.FromResult(person)

// Project individual fields from the Person output
override _.Update(state, input, personVar) =
{ LastPersonName = Var.map (fun p -> p.Name) personVar
LastPersonAge = Var.map (fun p -> p.Age) personVar }
```

# [C#](#tab/csharp)

```csharp
// Command that returns a structured Person type
public record Person(string Name, int Age);

public record RegistryState
{
public Var<string> LastPersonName { get; init; }
public Var<int> LastPersonAge { get; init; }
}

public class AddPersonCommand : Command<PersonRegistry, RegistryState, (string, int), Person>
{
public override Task<Person> Execute(PersonRegistry sut, Env env, RegistryState state, (string, int) input)
{
var (name, age) = input;
var person = sut.AddPerson(name, age);
return Task.FromResult(person);
}

// Project individual fields from the Person output
public override RegistryState Update(RegistryState state, (string, int) input, Var<Person> personVar) =>
state with
{
LastPersonName = personVar.Select(p => p.Name),
LastPersonAge = personVar.Select(p => p.Age)
};
}
```

---

**How it works:** Both projected variables (`LastPersonName` and `LastPersonAge`) share the same underlying variable name - they point to the same `Person` object in the environment. When you resolve them, the projection function is applied to extract the specific field.

**You can chain projections:**

# [F#](#tab/fsharp)

```fsharp
override _.Update(state, input, personVar) =
let nameVar = Var.map (fun p -> p.Name) personVar
let nameLengthVar = Var.map String.length nameVar
{ state with NameLength = nameLengthVar }
```

# [C#](#tab/csharp)

```csharp
public override RegistryState Update(RegistryState state, (string, int) input, Var<Person> personVar)
{
var nameVar = personVar.Select(p => p.Name);
var nameLengthVar = nameVar.Select(name => name.Length);
return state with { NameLength = nameLengthVar };
}
```

---

This is particularly useful when you need to pass different parts of a command's output to different subsequent commands.

### 6. Ensure: Verifying Correctness

**When it runs:** After execution and state update
Expand Down
9 changes: 8 additions & 1 deletion src/Hedgehog.Stateful/Hedgehog.Stateful.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,24 @@
<ProjectReference Include="..\Hedgehog\Hedgehog.fsproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Hedgehog.Stateful.Tests.FSharp" />
<InternalsVisibleTo Include="Hedgehog.Stateful.Tests.CSharp" />
</ItemGroup>

<ItemGroup>
<Compile Include="Types.fs" />
<Compile Include="Var.fs" />
<Compile Include="StateFormatter.fs" />
<Compile Include="Action.fs" />
<Compile Include="ICommand.fs" />
<Compile Include="Command.fs" />
<Compile Include="Sequential.fs" />
<Compile Include="Parallel.fs" />
<Compile Include="SequentialSpecification.fs" />
<Compile Include="ParallelSpecification.fs" />
<Compile Include="Linq\NoInput.fs" />
<Compile Include="Linq\VarExtensions.fs" />
<Compile Include="Linq\NoValue.fs" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
namespace Hedgehog.Stateful.Linq

/// Represents the absence of input for commands that don't require input parameters.
/// Represents the absence of value, that can be used for commands that don't require input parameters.
/// Similar to F#'s unit type, this is a zero-sized struct with a single instance.
[<Struct>]
[<StructuredFormatDisplay("{DisplayText}")>]
type NoInput =
static member val Value = Unchecked.defaultof<NoInput>
type NoValue =
static member val Value = Unchecked.defaultof<NoValue>
member private this.DisplayText = ""
24 changes: 24 additions & 0 deletions src/Hedgehog.Stateful/Linq/VarExtensions.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Hedgehog.Stateful.Linq

open System
open System.Runtime.CompilerServices
open Hedgehog.Stateful
open Hedgehog.Stateful.FSharp


/// <summary>
/// Extension methods for working with <c>Var&lt;T&gt;</c> in C#.
/// </summary>
[<AbstractClass; Sealed>]
type VarExtensions private () =

/// <summary>
/// Projects the value of a variable using a selector function.
/// This allows extracting fields from structured command outputs.
/// </summary>
/// <param name="var">The variable to project from.</param>
/// <param name="selector">A function to apply to the variable's value.</param>
/// <returns>A new variable with the projection applied.</returns>
[<Extension>]
static member Select(var: Var<'T>, selector: Func<'T, 'U>) : Var<'U> =
Var.map selector.Invoke var
11 changes: 6 additions & 5 deletions src/Hedgehog.Stateful/Parallel.fs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ module Parallel =
match result with
| ActionResult.Failure ex ->
do! Property.counterexample (fun () -> formatActionName action)
do! Property.counterexample (fun () -> $"Final state: %A{state}")
do! Property.counterexample (fun () -> $"Final state: %A{StateFormatter.formatForDisplay env state}")
return! Property.exn ex
| ActionResult.Success output ->
let name, env' = Env.freshName env
Expand All @@ -169,7 +169,7 @@ module Parallel =
match result with
| ActionResult.Failure ex ->
do! Property.counterexample (fun () -> formatActionName action)
do! Property.counterexample (fun () -> $"Final state: %A{state}")
do! Property.counterexample (fun () -> $"Final state: %A{StateFormatter.formatForDisplay env state}")
return! Property.exn ex
| ActionResult.Success output ->
prefixResults.Add(action.Id, output)
Expand All @@ -178,8 +178,9 @@ module Parallel =
env <- Env.add outputVar output env'
state <- action.Update state outputVar

// Save state before parallel branches (which is also before cleanup)
// Save state and env before parallel branches (which is also before cleanup)
let stateBeforeBranches = state
let envBeforeBranches = env

// Run branches in parallel
let runBranch (branch: Action<'TSystem, 'TState> list) : Async<Result<(Name * obj) list, exn>> =
Expand Down Expand Up @@ -214,7 +215,7 @@ module Parallel =
| Error ex, _ | _, Error ex ->
// Branch failed - report state before branches
property {
do! Property.counterexample (fun () -> $"Final state: %A{stateBeforeBranches}")
do! Property.counterexample (fun () -> $"Final state: %A{StateFormatter.formatForDisplay envBeforeBranches stateBeforeBranches}")
return! Property.exn ex
}
| Ok results1, Ok results2 ->
Expand All @@ -228,7 +229,7 @@ module Parallel =
if not linearizable then
property {
do! Property.counterexample (fun () -> "No valid interleaving found")
do! Property.counterexample (fun () -> $"Final state: %A{stateBeforeBranches}")
do! Property.counterexample (fun () -> $"Final state: %A{StateFormatter.formatForDisplay envBeforeBranches stateBeforeBranches}")
return! Property.failure
}
else
Expand Down
6 changes: 3 additions & 3 deletions src/Hedgehog.Stateful/Sequential.fs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ module Sequential =
// Add counterexample for the failing action before propagating the exception
do! Property.counterexample (fun () -> formatActionName action)
if action.Category <> ActionCategory.Cleanup then
do! Property.counterexample (fun () -> $"Failed at state: %A{state}")
do! Property.counterexample (fun () -> $"Failed at state: %A{StateFormatter.formatForDisplay env state}")
do! Property.exn ex

| ActionResult.Success output ->
Expand All @@ -220,7 +220,7 @@ module Sequential =
with ex ->
if action.Category <> ActionCategory.Cleanup then
property {
do! Property.counterexample (fun () -> $"Failed at state: %A{state1}")
do! Property.counterexample (fun () -> $"Failed at state: %A{StateFormatter.formatForDisplay env'' state1}")
do! Property.exn ex
}
else
Expand All @@ -229,6 +229,6 @@ module Sequential =
}

property {
do! Property.counterexample (fun () -> $"Initial state: %A{actions.Initial}")
do! Property.counterexample (fun () -> $"Initial state: %A{StateFormatter.formatForDisplay Env.empty actions.Initial}")
do! loop actions.Initial Env.empty actions.Steps
}
101 changes: 101 additions & 0 deletions src/Hedgehog.Stateful/StateFormatter.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
namespace Hedgehog.Stateful

open System
open System.Reflection
open System.Collections.Generic

/// <summary>
/// Utilities for formatting state with resolved variable values for display in test failures.
/// </summary>
[<RequireQualifiedAccess>]
module internal StateFormatter =

/// Check if a type is Var<T>
let private isVarType (t: Type) =
t.IsGenericType && t.GetGenericTypeDefinition() = typedefof<Var<_>>

/// Mutate a Var<T> object to set its ResolvedValue field for counterexample formatting
let private mutateVarForDisplay (env: Env) (varObj: obj) : unit =
if varObj = null then ()
else
try
// Call SetResolvedValue method using reflection
let setMethod = varObj.GetType().GetMethod("SetResolvedValue", BindingFlags.NonPublic ||| BindingFlags.Instance)
setMethod.Invoke(varObj, [| env |]) |> ignore
with
| _ -> () // If resolution fails, leave the var as-is

/// Recursively walk an object and mutate all Var<T> fields/properties for display
let rec private mutateVarsInObject (env: Env) (visited: HashSet<obj>) (obj: obj) : unit =
if obj = null then ()
else
let objType = obj.GetType()

// Avoid infinite loops on circular references
if visited.Contains(obj) then ()
else
visited.Add(obj) |> ignore

// Check if this is a Var<T> itself
if isVarType objType then
mutateVarForDisplay env obj

// Handle primitive types and strings - no traversal needed
elif objType.IsPrimitive || objType = typeof<string> || objType.IsEnum then
()

// Handle collections (including arrays) and other types
elif obj :? System.Collections.IEnumerable then
let enumerable = obj :?> System.Collections.IEnumerable
for element in enumerable do
if element <> null then
mutateVarsInObject env visited element

// Also traverse fields/properties in case it's a complex collection
traverseFieldsAndProperties env visited obj objType

// Handle types with fields/properties
else
traverseFieldsAndProperties env visited obj objType

and private traverseFieldsAndProperties (env: Env) (visited: HashSet<obj>) (obj: obj) (objType: Type) : unit =
try
// Traverse all readable properties
let properties =
objType.GetProperties(BindingFlags.Public ||| BindingFlags.Instance)
|> Array.filter _.CanRead

for prop in properties do
try
let value = prop.GetValue(obj)
if value <> null then
mutateVarsInObject env visited value
with
| _ -> () // Skip properties that can't be read

// Traverse all fields
let fields =
objType.GetFields(BindingFlags.Public ||| BindingFlags.Instance)

for field in fields do
try
let value = field.GetValue(obj)
if value <> null then
mutateVarsInObject env visited value
with
| _ -> () // Skip fields that can't be read
with
| _ -> () // If anything fails, continue silently

/// <summary>
/// Format a state object for display by resolving all Var&lt;T&gt; fields to their concrete values.
/// Mutates Var instances in-place by setting their ResolvedValue field.
/// This should only be called during counterexample formatting on test failure.
/// </summary>
/// <param name="env">The environment containing variable bindings.</param>
/// <param name="state">The state object to format.</param>
/// <returns>The same state object with vars mutated for display.</returns>
let formatForDisplay (env: Env) (state: 'TState) : 'TState =
let visited = HashSet<obj>(ReferenceEqualityComparer.Instance)
mutateVarsInObject env visited (box state)
state
Loading
Loading