Skip to content

Commit 9635160

Browse files
authored
Merge pull request #473 from hedgehogqa/project-var-state
Project var state
2 parents 38884a0 + 89efa6d commit 9635160

13 files changed

Lines changed: 699 additions & 71 deletions

File tree

docs/stateful/commands.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,98 @@ public override CounterState Update(CounterState state, bool input, Var<int> out
336336

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

339+
#### Projecting Fields from Structured Outputs
340+
341+
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:
342+
343+
# [F#](#tab/fsharp)
344+
345+
```fsharp
346+
// Command that returns a structured Person type
347+
type Person = {
348+
Name: string
349+
Age: int
350+
}
351+
352+
type RegistryState = {
353+
LastPersonName: Var<string>
354+
LastPersonAge: Var<int>
355+
}
356+
357+
type AddPersonCommand() =
358+
inherit Command<PersonRegistry, RegistryState, string * int, Person>()
359+
360+
override _.Execute(sut, env, state, (name, age)) =
361+
let person = sut.AddPerson(name, age)
362+
Task.FromResult(person)
363+
364+
// Project individual fields from the Person output
365+
override _.Update(state, input, personVar) =
366+
{ LastPersonName = Var.map (fun p -> p.Name) personVar
367+
LastPersonAge = Var.map (fun p -> p.Age) personVar }
368+
```
369+
370+
# [C#](#tab/csharp)
371+
372+
```csharp
373+
// Command that returns a structured Person type
374+
public record Person(string Name, int Age);
375+
376+
public record RegistryState
377+
{
378+
public Var<string> LastPersonName { get; init; }
379+
public Var<int> LastPersonAge { get; init; }
380+
}
381+
382+
public class AddPersonCommand : Command<PersonRegistry, RegistryState, (string, int), Person>
383+
{
384+
public override Task<Person> Execute(PersonRegistry sut, Env env, RegistryState state, (string, int) input)
385+
{
386+
var (name, age) = input;
387+
var person = sut.AddPerson(name, age);
388+
return Task.FromResult(person);
389+
}
390+
391+
// Project individual fields from the Person output
392+
public override RegistryState Update(RegistryState state, (string, int) input, Var<Person> personVar) =>
393+
state with
394+
{
395+
LastPersonName = personVar.Select(p => p.Name),
396+
LastPersonAge = personVar.Select(p => p.Age)
397+
};
398+
}
399+
```
400+
401+
---
402+
403+
**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.
404+
405+
**You can chain projections:**
406+
407+
# [F#](#tab/fsharp)
408+
409+
```fsharp
410+
override _.Update(state, input, personVar) =
411+
let nameVar = Var.map (fun p -> p.Name) personVar
412+
let nameLengthVar = Var.map String.length nameVar
413+
{ state with NameLength = nameLengthVar }
414+
```
415+
416+
# [C#](#tab/csharp)
417+
418+
```csharp
419+
public override RegistryState Update(RegistryState state, (string, int) input, Var<Person> personVar)
420+
{
421+
var nameVar = personVar.Select(p => p.Name);
422+
var nameLengthVar = nameVar.Select(name => name.Length);
423+
return state with { NameLength = nameLengthVar };
424+
}
425+
```
426+
427+
---
428+
429+
This is particularly useful when you need to pass different parts of a command's output to different subsequent commands.
430+
339431
### 6. Ensure: Verifying Correctness
340432

341433
**When it runs:** After execution and state update

src/Hedgehog.Stateful/Hedgehog.Stateful.fsproj

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,24 @@
2222
<ProjectReference Include="..\Hedgehog\Hedgehog.fsproj" />
2323
</ItemGroup>
2424

25+
<ItemGroup>
26+
<InternalsVisibleTo Include="Hedgehog.Stateful.Tests.FSharp" />
27+
<InternalsVisibleTo Include="Hedgehog.Stateful.Tests.CSharp" />
28+
</ItemGroup>
29+
2530
<ItemGroup>
2631
<Compile Include="Types.fs" />
2732
<Compile Include="Var.fs" />
33+
<Compile Include="StateFormatter.fs" />
2834
<Compile Include="Action.fs" />
2935
<Compile Include="ICommand.fs" />
3036
<Compile Include="Command.fs" />
3137
<Compile Include="Sequential.fs" />
3238
<Compile Include="Parallel.fs" />
3339
<Compile Include="SequentialSpecification.fs" />
3440
<Compile Include="ParallelSpecification.fs" />
35-
<Compile Include="Linq\NoInput.fs" />
41+
<Compile Include="Linq\VarExtensions.fs" />
42+
<Compile Include="Linq\NoValue.fs" />
3643
</ItemGroup>
3744

3845
<ItemGroup>
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
namespace Hedgehog.Stateful.Linq
22

3-
/// Represents the absence of input for commands that don't require input parameters.
3+
/// Represents the absence of value, that can be used for commands that don't require input parameters.
44
/// Similar to F#'s unit type, this is a zero-sized struct with a single instance.
55
[<Struct>]
66
[<StructuredFormatDisplay("{DisplayText}")>]
7-
type NoInput =
8-
static member val Value = Unchecked.defaultof<NoInput>
7+
type NoValue =
8+
static member val Value = Unchecked.defaultof<NoValue>
99
member private this.DisplayText = ""
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace Hedgehog.Stateful.Linq
2+
3+
open System
4+
open System.Runtime.CompilerServices
5+
open Hedgehog.Stateful
6+
open Hedgehog.Stateful.FSharp
7+
8+
9+
/// <summary>
10+
/// Extension methods for working with <c>Var&lt;T&gt;</c> in C#.
11+
/// </summary>
12+
[<AbstractClass; Sealed>]
13+
type VarExtensions private () =
14+
15+
/// <summary>
16+
/// Projects the value of a variable using a selector function.
17+
/// This allows extracting fields from structured command outputs.
18+
/// </summary>
19+
/// <param name="var">The variable to project from.</param>
20+
/// <param name="selector">A function to apply to the variable's value.</param>
21+
/// <returns>A new variable with the projection applied.</returns>
22+
[<Extension>]
23+
static member Select(var: Var<'T>, selector: Func<'T, 'U>) : Var<'U> =
24+
Var.map selector.Invoke var

src/Hedgehog.Stateful/Parallel.fs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ module Parallel =
152152
match result with
153153
| ActionResult.Failure ex ->
154154
do! Property.counterexample (fun () -> formatActionName action)
155-
do! Property.counterexample (fun () -> $"Final state: %A{state}")
155+
do! Property.counterexample (fun () -> $"Final state: %A{StateFormatter.formatForDisplay env state}")
156156
return! Property.exn ex
157157
| ActionResult.Success output ->
158158
let name, env' = Env.freshName env
@@ -169,7 +169,7 @@ module Parallel =
169169
match result with
170170
| ActionResult.Failure ex ->
171171
do! Property.counterexample (fun () -> formatActionName action)
172-
do! Property.counterexample (fun () -> $"Final state: %A{state}")
172+
do! Property.counterexample (fun () -> $"Final state: %A{StateFormatter.formatForDisplay env state}")
173173
return! Property.exn ex
174174
| ActionResult.Success output ->
175175
prefixResults.Add(action.Id, output)
@@ -178,8 +178,9 @@ module Parallel =
178178
env <- Env.add outputVar output env'
179179
state <- action.Update state outputVar
180180

181-
// Save state before parallel branches (which is also before cleanup)
181+
// Save state and env before parallel branches (which is also before cleanup)
182182
let stateBeforeBranches = state
183+
let envBeforeBranches = env
183184

184185
// Run branches in parallel
185186
let runBranch (branch: Action<'TSystem, 'TState> list) : Async<Result<(Name * obj) list, exn>> =
@@ -214,7 +215,7 @@ module Parallel =
214215
| Error ex, _ | _, Error ex ->
215216
// Branch failed - report state before branches
216217
property {
217-
do! Property.counterexample (fun () -> $"Final state: %A{stateBeforeBranches}")
218+
do! Property.counterexample (fun () -> $"Final state: %A{StateFormatter.formatForDisplay envBeforeBranches stateBeforeBranches}")
218219
return! Property.exn ex
219220
}
220221
| Ok results1, Ok results2 ->
@@ -228,7 +229,7 @@ module Parallel =
228229
if not linearizable then
229230
property {
230231
do! Property.counterexample (fun () -> "No valid interleaving found")
231-
do! Property.counterexample (fun () -> $"Final state: %A{stateBeforeBranches}")
232+
do! Property.counterexample (fun () -> $"Final state: %A{StateFormatter.formatForDisplay envBeforeBranches stateBeforeBranches}")
232233
return! Property.failure
233234
}
234235
else

src/Hedgehog.Stateful/Sequential.fs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ module Sequential =
201201
// Add counterexample for the failing action before propagating the exception
202202
do! Property.counterexample (fun () -> formatActionName action)
203203
if action.Category <> ActionCategory.Cleanup then
204-
do! Property.counterexample (fun () -> $"Failed at state: %A{state}")
204+
do! Property.counterexample (fun () -> $"Failed at state: %A{StateFormatter.formatForDisplay env state}")
205205
do! Property.exn ex
206206

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

231231
property {
232-
do! Property.counterexample (fun () -> $"Initial state: %A{actions.Initial}")
232+
do! Property.counterexample (fun () -> $"Initial state: %A{StateFormatter.formatForDisplay Env.empty actions.Initial}")
233233
do! loop actions.Initial Env.empty actions.Steps
234234
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
namespace Hedgehog.Stateful
2+
3+
open System
4+
open System.Reflection
5+
open System.Collections.Generic
6+
7+
/// <summary>
8+
/// Utilities for formatting state with resolved variable values for display in test failures.
9+
/// </summary>
10+
[<RequireQualifiedAccess>]
11+
module internal StateFormatter =
12+
13+
/// Check if a type is Var<T>
14+
let private isVarType (t: Type) =
15+
t.IsGenericType && t.GetGenericTypeDefinition() = typedefof<Var<_>>
16+
17+
/// Mutate a Var<T> object to set its ResolvedValue field for counterexample formatting
18+
let private mutateVarForDisplay (env: Env) (varObj: obj) : unit =
19+
if varObj = null then ()
20+
else
21+
try
22+
// Call SetResolvedValue method using reflection
23+
let setMethod = varObj.GetType().GetMethod("SetResolvedValue", BindingFlags.NonPublic ||| BindingFlags.Instance)
24+
setMethod.Invoke(varObj, [| env |]) |> ignore
25+
with
26+
| _ -> () // If resolution fails, leave the var as-is
27+
28+
/// Recursively walk an object and mutate all Var<T> fields/properties for display
29+
let rec private mutateVarsInObject (env: Env) (visited: HashSet<obj>) (obj: obj) : unit =
30+
if obj = null then ()
31+
else
32+
let objType = obj.GetType()
33+
34+
// Avoid infinite loops on circular references
35+
if visited.Contains(obj) then ()
36+
else
37+
visited.Add(obj) |> ignore
38+
39+
// Check if this is a Var<T> itself
40+
if isVarType objType then
41+
mutateVarForDisplay env obj
42+
43+
// Handle primitive types and strings - no traversal needed
44+
elif objType.IsPrimitive || objType = typeof<string> || objType.IsEnum then
45+
()
46+
47+
// Handle collections (including arrays) and other types
48+
elif obj :? System.Collections.IEnumerable then
49+
let enumerable = obj :?> System.Collections.IEnumerable
50+
for element in enumerable do
51+
if element <> null then
52+
mutateVarsInObject env visited element
53+
54+
// Also traverse fields/properties in case it's a complex collection
55+
traverseFieldsAndProperties env visited obj objType
56+
57+
// Handle types with fields/properties
58+
else
59+
traverseFieldsAndProperties env visited obj objType
60+
61+
and private traverseFieldsAndProperties (env: Env) (visited: HashSet<obj>) (obj: obj) (objType: Type) : unit =
62+
try
63+
// Traverse all readable properties
64+
let properties =
65+
objType.GetProperties(BindingFlags.Public ||| BindingFlags.Instance)
66+
|> Array.filter _.CanRead
67+
68+
for prop in properties do
69+
try
70+
let value = prop.GetValue(obj)
71+
if value <> null then
72+
mutateVarsInObject env visited value
73+
with
74+
| _ -> () // Skip properties that can't be read
75+
76+
// Traverse all fields
77+
let fields =
78+
objType.GetFields(BindingFlags.Public ||| BindingFlags.Instance)
79+
80+
for field in fields do
81+
try
82+
let value = field.GetValue(obj)
83+
if value <> null then
84+
mutateVarsInObject env visited value
85+
with
86+
| _ -> () // Skip fields that can't be read
87+
with
88+
| _ -> () // If anything fails, continue silently
89+
90+
/// <summary>
91+
/// Format a state object for display by resolving all Var&lt;T&gt; fields to their concrete values.
92+
/// Mutates Var instances in-place by setting their ResolvedValue field.
93+
/// This should only be called during counterexample formatting on test failure.
94+
/// </summary>
95+
/// <param name="env">The environment containing variable bindings.</param>
96+
/// <param name="state">The state object to format.</param>
97+
/// <returns>The same state object with vars mutated for display.</returns>
98+
let formatForDisplay (env: Env) (state: 'TState) : 'TState =
99+
let visited = HashSet<obj>(ReferenceEqualityComparer.Instance)
100+
mutateVarsInObject env visited (box state)
101+
state

0 commit comments

Comments
 (0)