diff --git a/docs/stateful/commands.md b/docs/stateful/commands.md index cfacb715..6f339ed6 100644 --- a/docs/stateful/commands.md +++ b/docs/stateful/commands.md @@ -336,6 +336,98 @@ public override CounterState Update(CounterState state, bool input, Var 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 + LastPersonAge: Var +} + +type AddPersonCommand() = + inherit Command() + + 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 LastPersonName { get; init; } + public Var LastPersonAge { get; init; } +} + +public class AddPersonCommand : Command +{ + public override Task 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 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 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 diff --git a/src/Hedgehog.Stateful/Hedgehog.Stateful.fsproj b/src/Hedgehog.Stateful/Hedgehog.Stateful.fsproj index 30f24681..51061446 100644 --- a/src/Hedgehog.Stateful/Hedgehog.Stateful.fsproj +++ b/src/Hedgehog.Stateful/Hedgehog.Stateful.fsproj @@ -22,9 +22,15 @@ + + + + + + @@ -32,7 +38,8 @@ - + + diff --git a/src/Hedgehog.Stateful/Linq/NoInput.fs b/src/Hedgehog.Stateful/Linq/NoValue.fs similarity index 54% rename from src/Hedgehog.Stateful/Linq/NoInput.fs rename to src/Hedgehog.Stateful/Linq/NoValue.fs index e734f3f3..55810e85 100644 --- a/src/Hedgehog.Stateful/Linq/NoInput.fs +++ b/src/Hedgehog.Stateful/Linq/NoValue.fs @@ -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. [] [] -type NoInput = - static member val Value = Unchecked.defaultof +type NoValue = + static member val Value = Unchecked.defaultof member private this.DisplayText = "" diff --git a/src/Hedgehog.Stateful/Linq/VarExtensions.fs b/src/Hedgehog.Stateful/Linq/VarExtensions.fs new file mode 100644 index 00000000..d8604c58 --- /dev/null +++ b/src/Hedgehog.Stateful/Linq/VarExtensions.fs @@ -0,0 +1,24 @@ +namespace Hedgehog.Stateful.Linq + +open System +open System.Runtime.CompilerServices +open Hedgehog.Stateful +open Hedgehog.Stateful.FSharp + + +/// +/// Extension methods for working with Var<T> in C#. +/// +[] +type VarExtensions private () = + + /// + /// Projects the value of a variable using a selector function. + /// This allows extracting fields from structured command outputs. + /// + /// The variable to project from. + /// A function to apply to the variable's value. + /// A new variable with the projection applied. + [] + static member Select(var: Var<'T>, selector: Func<'T, 'U>) : Var<'U> = + Var.map selector.Invoke var diff --git a/src/Hedgehog.Stateful/Parallel.fs b/src/Hedgehog.Stateful/Parallel.fs index c709cb8b..e7053554 100644 --- a/src/Hedgehog.Stateful/Parallel.fs +++ b/src/Hedgehog.Stateful/Parallel.fs @@ -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 @@ -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) @@ -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> = @@ -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 -> @@ -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 diff --git a/src/Hedgehog.Stateful/Sequential.fs b/src/Hedgehog.Stateful/Sequential.fs index 4ac5af90..5b734123 100644 --- a/src/Hedgehog.Stateful/Sequential.fs +++ b/src/Hedgehog.Stateful/Sequential.fs @@ -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 -> @@ -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 @@ -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 } diff --git a/src/Hedgehog.Stateful/StateFormatter.fs b/src/Hedgehog.Stateful/StateFormatter.fs new file mode 100644 index 00000000..6c77c8d9 --- /dev/null +++ b/src/Hedgehog.Stateful/StateFormatter.fs @@ -0,0 +1,101 @@ +namespace Hedgehog.Stateful + +open System +open System.Reflection +open System.Collections.Generic + +/// +/// Utilities for formatting state with resolved variable values for display in test failures. +/// +[] +module internal StateFormatter = + + /// Check if a type is Var + let private isVarType (t: Type) = + t.IsGenericType && t.GetGenericTypeDefinition() = typedefof> + + /// Mutate a Var 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 fields/properties for display + let rec private mutateVarsInObject (env: Env) (visited: HashSet) (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 itself + if isVarType objType then + mutateVarForDisplay env obj + + // Handle primitive types and strings - no traversal needed + elif objType.IsPrimitive || objType = typeof || 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) (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 + + /// + /// Format a state object for display by resolving all Var<T> 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. + /// + /// The environment containing variable bindings. + /// The state object to format. + /// The same state object with vars mutated for display. + let formatForDisplay (env: Env) (state: 'TState) : 'TState = + let visited = HashSet(ReferenceEqualityComparer.Instance) + mutateVarsInObject env visited (box state) + state diff --git a/src/Hedgehog.Stateful/Types.fs b/src/Hedgehog.Stateful/Types.fs index b19bd7fd..0f44304d 100644 --- a/src/Hedgehog.Stateful/Types.fs +++ b/src/Hedgehog.Stateful/Types.fs @@ -1,9 +1,5 @@ namespace Hedgehog.Stateful -open System.Threading.Tasks -open Hedgehog - - /// /// Unique identifier for a symbolic variable. /// @@ -40,17 +36,46 @@ type Var<'T> = private { /// The optional default value for the variable. /// Default: 'T option + /// + /// Transform function applied when resolving the variable from the environment. + /// Handles unboxing and any projections/mappings applied via Var.map. + /// + Transform: obj -> 'T + /// + /// Mutable field used internally for displaying resolved values in counterexamples. + /// Only mutated during test failure formatting, not during normal test execution. + /// + /// The purpose is to report failure with the concrete state values, + /// to make dev experience better when debugging failed tests. + /// + /// NOT INTENDED TO BE USED FOR ANY OTHER PURPOSE. + /// + mutable ResolvedValue: 'T option } with + /// + /// Gets the unique integer name of the variable. + /// + member this.VarName = this.Name + + /// + /// Gets whether the variable is bound to a generated value. + /// + member this.IsBounded = this.Bounded + member private this.DisplayText = - if this.Bounded then - match this.Default with - | Some d -> $"Var_%d{this.Name} (default=%A{d}))" - | None -> $"Var_%d{this.Name}" - else - match this.Default with - | Some d -> $"%A{d} (default)" - | None -> " (symbolic)" + // If resolved for display (during counterexample formatting), show the resolved value + match this.ResolvedValue with + | Some resolved -> $"%A{resolved}" + | None -> + if this.Bounded then + match this.Default with + | Some d -> $"%A{d}" + | None -> $"Var_%d{this.Name}" + else + match this.Default with + | Some d -> $"%A{d}" + | None -> " (symbolic)" /// /// Resolve the variable using its default if not found in the environment. @@ -64,7 +89,7 @@ with | None -> failwithf "Symbolic var must have a default value" else match Map.tryFind (Name this.Name) env.values with - | Some v -> unbox<'T> v + | Some v -> this.Transform v | None -> match this.Default with | Some d -> d @@ -81,9 +106,24 @@ with fallback // Override default for unbounded else match Map.tryFind (Name this.Name) env.values with - | Some v -> unbox<'T> v + | Some v -> this.Transform v | None -> fallback + /// + /// Set the resolved value for display purposes during counterexample formatting. + /// This should only be called internally by StateFormatter during test failure formatting. + /// + /// The environment to resolve the variable from. + member internal this.SetResolvedValue(env: Env) : unit = + try + let resolved = this.Resolve(env) + this.ResolvedValue <- Some resolved + with + | _ -> () // If resolution fails, leave ResolvedForDisplay as None + + static member internal CreateSymbolic(value: 'T) : Var<'T> = + { Name = -1; Bounded = false; Default = Some value; Transform = unbox<'T>; ResolvedValue = Some value } + module internal Env = /// Empty environment diff --git a/src/Hedgehog.Stateful/Var.fs b/src/Hedgehog.Stateful/Var.fs index c24a5200..56ff6c34 100644 --- a/src/Hedgehog.Stateful/Var.fs +++ b/src/Hedgehog.Stateful/Var.fs @@ -13,7 +13,7 @@ module Var = /// A new symbolic (unbound) Var<T> with the given default value. [] let symbolic (defaultValue: 'T) : Var<'T> = - { Name = -1; Bounded = false; Default = Some defaultValue } + { Name = -1; Bounded = false; Default = Some defaultValue; Transform = unbox<'T>; ResolvedValue = None } namespace Hedgehog.Stateful.FSharp @@ -42,25 +42,48 @@ module Var = v.ResolveOr(env, fallback) /// - /// Resolve a variable, returning None if not found in the environment. + /// Resolve a variable, returning Error if not found in the environment or if resolution fails. /// /// The variable to resolve. /// The environment to resolve the variable from. - /// The resolved value as Some, or None if not found. - let tryResolve<'T> (v: Var<'T>) (env: Env) : 'T option = + /// The resolved value as Ok, or Error with failure reason if not found or transform fails. + let tryResolve<'T> (v: Var<'T>) (env: Env) : Result<'T, string> = if not v.Bounded then - v.Default + match v.Default with + | Some d -> Ok d + | None -> Error "Symbolic variable has no default value" else - env.values - |> Map.tryFind (Name v.Name) - |> Option.map unbox<'T> + match env.values |> Map.tryFind (Name v.Name) with + | None -> Error $"Var_{v.Name} not found in environment" + | Some value -> + try + Ok (v.Transform value) + with ex -> + Error $"Transform failed: {ex.GetType().Name}" + + /// + /// Map a function over a variable, creating a new variable that projects + /// a value from the original variable's output. This allows extracting + /// fields from structured command outputs. + /// + /// The projection function to apply. + /// The variable to map over. + /// A new variable with the projection applied. + let map (f: 'T -> 'U) (v: Var<'T>) : Var<'U> = + { Name = v.Name + Bounded = v.Bounded + Default = v.Default |> Option.map f + Transform = v.Transform >> f + ResolvedValue = None } /// Create a bounded var from a Name (used during generation) let internal bound (name: Name) : Var<'T> = let (Name n) = name - { Name = n; Bounded = true; Default = None } + { Name = n; Bounded = true; Default = None; Transform = unbox<'T>; ResolvedValue = None } let internal convertFrom<'T> (v: Var) : Var<'T> = { Name = v.Name Bounded = v.Bounded - Default = v.Default |> Option.map unbox<'T> } + Default = v.Default |> Option.map unbox<'T> + Transform = unbox<'T> + ResolvedValue = None } diff --git a/tests/Hedgehog.Stateful.Tests.CSharp/SequentialCounterSpec.cs b/tests/Hedgehog.Stateful.Tests.CSharp/SequentialCounterSpec.cs index 06a084b2..e04221be 100644 --- a/tests/Hedgehog.Stateful.Tests.CSharp/SequentialCounterSpec.cs +++ b/tests/Hedgehog.Stateful.Tests.CSharp/SequentialCounterSpec.cs @@ -41,27 +41,27 @@ public record CounterState /// /// Increment command - increments the counter and returns new value /// -public class IncrementCommand : Command +public class IncrementCommand : Command { public override string Name => "Increment"; public override bool Precondition(CounterState state) => true; - public override bool Require(Env env, CounterState state, NoInput input) => Precondition(state); + public override bool Require(Env env, CounterState state, NoValue value) => Precondition(state); - public override Task Execute(Counter sut, Env env, CounterState state, NoInput input) + public override Task Execute(Counter sut, Env env, CounterState state, NoValue value) { sut.Increment(); var result = sut.Get(); return Task.FromResult(result); } - public override Gen Generate(CounterState state) => - Gen.Constant(NoInput.Value); + public override Gen Generate(CounterState state) => + Gen.Constant(NoValue.Value); - public override CounterState Update(CounterState state, NoInput input, Var outputVar) => + public override CounterState Update(CounterState state, NoValue value, Var outputVar) => state with { CurrentCount = outputVar }; - public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoInput input, int result) + public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoValue value, int result) { var oldCount = oldState.CurrentCount.Resolve(env); return result == oldCount + 1; @@ -71,51 +71,51 @@ public override bool Ensure(Env env, CounterState oldState, CounterState newStat /// /// AddRandom command - adds a random value to the counter /// -public class AddRandomCommand : Command +public class AddRandomCommand : Command { public override string Name => "AddRandom"; public override bool Precondition(CounterState state) => true; - public override bool Require(Env env, CounterState state, NoInput input) => Precondition(state); + public override bool Require(Env env, CounterState state, NoValue value) => Precondition(state); - public override Task Execute(Counter sut, Env env, CounterState state, NoInput input) => + public override Task Execute(Counter sut, Env env, CounterState state, NoValue value) => Task.FromResult(sut.AddRandom()); - public override Gen Generate(CounterState state) => - Gen.Constant(NoInput.Value); + public override Gen Generate(CounterState state) => + Gen.Constant(NoValue.Value); - public override CounterState Update(CounterState state, NoInput input, Var outputVar) => + public override CounterState Update(CounterState state, NoValue value, Var outputVar) => state with { CurrentCount = outputVar }; - public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoInput input, int output) => true; + public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoValue value, int output) => true; } /// /// Decrement command - decrements the counter and returns new value /// -public class DecrementCommand : Command +public class DecrementCommand : Command { public override string Name => "Decrement"; public override bool Precondition(CounterState state) => true; - public override bool Require(Env env, CounterState state, NoInput input) => Precondition(state); + public override bool Require(Env env, CounterState state, NoValue value) => Precondition(state); - public override Task Execute(Counter sut, Env env, CounterState state, NoInput input) + public override Task Execute(Counter sut, Env env, CounterState state, NoValue value) { sut.Decrement(); var result = sut.Get(); return Task.FromResult(result); } - public override Gen Generate(CounterState state) => - Gen.Constant(NoInput.Value); + public override Gen Generate(CounterState state) => + Gen.Constant(NoValue.Value); - public override CounterState Update(CounterState state, NoInput input, Var outputVar) => + public override CounterState Update(CounterState state, NoValue value, Var outputVar) => state with { CurrentCount = outputVar }; - public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoInput input, int result) + public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoValue value, int result) { var oldCount = oldState.CurrentCount.Resolve(env); return result == oldCount - 1; @@ -125,28 +125,28 @@ public override bool Ensure(Env env, CounterState oldState, CounterState newStat /// /// Reset command - resets the counter to 0 /// -public class ResetCommand : Command +public class ResetCommand : Command { public override string Name => "Reset"; public override bool Precondition(CounterState state) => true; - public override bool Require(Env env, CounterState state, NoInput input) => Precondition(state); + public override bool Require(Env env, CounterState state, NoValue value) => Precondition(state); - public override Task Execute(Counter sut, Env env, CounterState state, NoInput input) + public override Task Execute(Counter sut, Env env, CounterState state, NoValue value) { sut.Reset(); var result = sut.Get(); return Task.FromResult(result); } - public override Gen Generate(CounterState state) => - Gen.Constant(NoInput.Value); + public override Gen Generate(CounterState state) => + Gen.Constant(NoValue.Value); - public override CounterState Update(CounterState state, NoInput input, Var outputVar) => + public override CounterState Update(CounterState state, NoValue value, Var outputVar) => state with { CurrentCount = outputVar }; - public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoInput input, int result) + public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoValue value, int result) { // Reset always sets to 0 return result == 0; @@ -186,23 +186,23 @@ public override bool Ensure(Env env, CounterState oldState, CounterState newStat /// /// Get command - returns the current counter value /// -public class GetCommand : Command +public class GetCommand : Command { public override string Name => "Get"; public override bool Precondition(CounterState state) => true; - public override bool Require(Env env, CounterState state, NoInput input) => Precondition(state); + public override bool Require(Env env, CounterState state, NoValue value) => Precondition(state); - public override Task Execute(Counter sut, Env env, CounterState state, NoInput input) => + public override Task Execute(Counter sut, Env env, CounterState state, NoValue value) => Task.FromResult(sut.Get()); - public override Gen Generate(CounterState state) => - Gen.Constant(NoInput.Value); + public override Gen Generate(CounterState state) => + Gen.Constant(NoValue.Value); - public override CounterState Update(CounterState state, NoInput input, Var outputVar) => + public override CounterState Update(CounterState state, NoValue value, Var outputVar) => state with { CurrentCount = outputVar }; - public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoInput input, int result) + public override bool Ensure(Env env, CounterState oldState, CounterState newState, NoValue value, int result) { return result == oldState.CurrentCount.Resolve(env); } diff --git a/tests/Hedgehog.Stateful.Tests.FSharp/Hedgehog.Stateful.Tests.FSharp.fsproj b/tests/Hedgehog.Stateful.Tests.FSharp/Hedgehog.Stateful.Tests.FSharp.fsproj index b5bc0148..24cc0e7b 100644 --- a/tests/Hedgehog.Stateful.Tests.FSharp/Hedgehog.Stateful.Tests.FSharp.fsproj +++ b/tests/Hedgehog.Stateful.Tests.FSharp/Hedgehog.Stateful.Tests.FSharp.fsproj @@ -14,6 +14,8 @@ + + diff --git a/tests/Hedgehog.Stateful.Tests.FSharp/StateFormatterSpec.fs b/tests/Hedgehog.Stateful.Tests.FSharp/StateFormatterSpec.fs new file mode 100644 index 00000000..1a8a2f09 --- /dev/null +++ b/tests/Hedgehog.Stateful.Tests.FSharp/StateFormatterSpec.fs @@ -0,0 +1,206 @@ +module Hedgehog.Stateful.Tests.StateFormatterSpec + +open Hedgehog.Stateful +open Hedgehog.Stateful.FSharp +open Xunit + +// Test types with various F# constructs +type MyUnion = + | CaseWithVar of Var + | CaseTwoVars of Var * Var + | CaseNoVars of int + +type NestedState = { + OptionalVar: Var option + ResultVar: Result, string> + UnionField: MyUnion + ListOfVars: Var list +} + +type SimpleRecord = { X: Var; Y: Var } + +type Node = { Value: Var; mutable Next: Node option } + +[] +let ``StateFormatter resolves vars inside Option`` () = + let var1 = Var.bound (Name 0) + let state = { + OptionalVar = Some var1 + ResultVar = Error "not used" + UnionField = CaseNoVars 123 + ListOfVars = [] + } + + let env = Env.empty |> Env.add var1 42 + + StateFormatter.formatForDisplay env state |> ignore + + Assert.Contains("42", $"%A{var1}") + +[] +let ``StateFormatter resolves vars inside Result Ok`` () = + let var2: Var = Var.bound (Name 0) + let state = { + OptionalVar = None + ResultVar = Ok var2 + UnionField = CaseNoVars 123 + ListOfVars = [] + } + + let env = Env.empty |> Env.add var2 "hello" + + StateFormatter.formatForDisplay env state |> ignore + + Assert.Contains("\"hello\"", $"%A{var2}") + +[] +let ``StateFormatter resolves vars inside custom union cases`` () = + let var1 = Var.bound (Name 0) + let var2: Var = Var.bound (Name 1) + let var3: Var = Var.bound (Name 2) + + let state = { + OptionalVar = None + ResultVar = Error "not used" + UnionField = CaseTwoVars(var2, var3) + ListOfVars = [var1] + } + + let env = + Env.empty + |> Env.add var1 42 + |> Env.add var2 "world" + |> Env.add var3 true + + StateFormatter.formatForDisplay env state |> ignore + + Assert.Contains("42", $"%A{var1}") + Assert.Contains("\"world\"", $"%A{var2}") + Assert.Contains("true", $"%A{var3}") + +[] +let ``StateFormatter handles nested Option Some Some`` () = + let var1: Var = Var.bound (Name 0) + let nestedOption = Some (Some var1) + + let env = + Env.empty + |> Env.add var1 "deeply nested" + + StateFormatter.formatForDisplay env nestedOption |> ignore + + Assert.Contains("\"deeply nested\"", sprintf "%A" var1) + +[] +let ``StateFormatter handles vars in lists`` () = + let var1 = Var.bound (Name 0) + let var2 = Var.bound (Name 1) + let var3 = Var.bound (Name 2) + + let state = { + OptionalVar = None + ResultVar = Error "not used" + UnionField = CaseNoVars 0 + ListOfVars = [var1; var2; var3] + } + + let env = + Env.empty + |> Env.add var1 10 + |> Env.add var2 20 + |> Env.add var3 30 + + StateFormatter.formatForDisplay env state |> ignore + + Assert.Contains("10", $"%A{var1}") + Assert.Contains("20", $"%A{var2}") + Assert.Contains("30", $"%A{var3}") + +[] +let ``StateFormatter preserves object identity`` () = + let var1 = Var.bound (Name 0) + let state = { + OptionalVar = Some var1 + ResultVar = Error "test" + UnionField = CaseNoVars 99 + ListOfVars = [] + } + + let env = Env.empty |> Env.add var1 42 + + let formatted = StateFormatter.formatForDisplay env state + + Assert.True(obj.ReferenceEquals(state, formatted)) + +[] +let ``StateFormatter handles Choice types`` () = + let var1 = Var.bound (Name 0) + let var2: Var = Var.bound (Name 1) + let var3: Var = Var.bound (Name 2) + + let choice1: Choice, string, bool> = Choice1Of3 var1 + let choice2: Choice, bool> = Choice2Of3 var2 + let choice3: Choice> = Choice3Of3 var3 + + let env = + Env.empty + |> Env.add var1 100 + |> Env.add var2 "choice" + |> Env.add var3 false + + StateFormatter.formatForDisplay env choice1 |> ignore + StateFormatter.formatForDisplay env choice2 |> ignore + StateFormatter.formatForDisplay env choice3 |> ignore + + Assert.Contains("100", $"%A{var1}") + Assert.Contains("\"choice\"", $"%A{var2}") + Assert.Contains("false", $"%A{var3}") + +[] +let ``StateFormatter handles vars in arrays`` () = + let var1 = Var.bound (Name 0) + let var2 = Var.bound (Name 1) + + let arr = [| var1; var2 |] + + let env = + Env.empty + |> Env.add var1 5 + |> Env.add var2 10 + + StateFormatter.formatForDisplay env arr |> ignore + + Assert.Contains("5", $"%A{var1}") + Assert.Contains("10", $"%A{var2}") + +[] +let ``StateFormatter handles vars in records`` () = + let var1 = Var.bound (Name 0) + let var2: Var = Var.bound (Name 1) + + let record = { X = var1; Y = var2 } + + let env = + Env.empty + |> Env.add var1 999 + |> Env.add var2 "record field" + + StateFormatter.formatForDisplay env record |> ignore + + Assert.Contains("999", $"%A{var1}") + Assert.Contains("\"record field\"", $"%A{var2}") + +[] +let ``StateFormatter handles circular references without infinite loop`` () = + let var1 = Var.bound (Name 0) + let node = { Value = var1; Next = None } + node.Next <- Some node // Create circular reference + + let env = + Env.empty + |> Env.add var1 42 + + let formatted = StateFormatter.formatForDisplay env node + + Assert.Contains("42", $"%A{var1}") + Assert.True(obj.ReferenceEquals(node, formatted)) diff --git a/tests/Hedgehog.Stateful.Tests.FSharp/VarMapSpec.fs b/tests/Hedgehog.Stateful.Tests.FSharp/VarMapSpec.fs new file mode 100644 index 00000000..55f22573 --- /dev/null +++ b/tests/Hedgehog.Stateful.Tests.FSharp/VarMapSpec.fs @@ -0,0 +1,132 @@ +module Hedgehog.Stateful.Tests.VarMapSpec + +open System.Threading.Tasks +open Hedgehog.FSharp +open Hedgehog.Linq +open Hedgehog.Stateful +open Hedgehog.Stateful.FSharp +open Xunit + +// A structured result type +type Person = { + Name: string + Age: int +} + +// SUT that manages people +type PersonRegistry() = + let mutable people : Person list = [] + + member _.AddPerson(name: string, age: int) : Person = + let person = { Name = name; Age = age } + people <- person :: people + person + + member _.GetPeople() = people + member _.Clear() = people <- [] + +// State that tracks individual fields from the last added person +type RegistryState = { + LastPersonName: Var + LastPersonAge: Var +} + +/// Command that adds a person and returns the structured Person result +type AddPersonCommand() = + inherit Command() + + override _.Name = "AddPerson" + override _.Precondition _ = true + + override _.Execute(registry, _, _, (name, age)) = + let person = registry.AddPerson(name, age) + Task.FromResult(person) + + override _.Generate _ = + Gen.zip (Gen.alpha |> Gen.string (Range.linear 1 10)) + (Gen.int32 (Range.linear 0 100)) + + // Use Var.map to project individual fields from the Person result + override _.Update(_, _, personVar) = + { LastPersonName = Var.map (fun p -> p.Name) personVar + LastPersonAge = Var.map (fun p -> p.Age) personVar } + + override _.Ensure(_env, _oldState, _, (name, age), result) = + // Verify the returned person has correct values + result.Name = name && result.Age = age + +/// Command that verifies we can resolve the projected fields +type VerifyLastPersonCommand() = + inherit Command() + + override _.Name = "VerifyLastPerson" + override _.Precondition state = + // Only run if we have a bounded var (at least one person added) + state.LastPersonName.IsBounded + + override _.Execute(registry, _, _, _) = + let people = registry.GetPeople() + let hasData = not (List.isEmpty people) + Task.FromResult(hasData) + + override _.Generate _ = Gen.constant () + + override _.Update(state, _, _) = state // No state change + + override _.Ensure(env, state, _, _, result) = + if result then + // Verify we can resolve the mapped fields + let name = state.LastPersonName.Resolve(env) + let age = state.LastPersonAge.Resolve(env) + + // Name should be non-empty and age should be in valid range + not (System.String.IsNullOrWhiteSpace(name)) && age >= 0 && age <= 100 + else + true + +/// Specification for testing Var.map +type VarMapSpec() = + inherit SequentialSpecification() + + override _.SetupCommands = [||] + + override _.InitialState = + { LastPersonName = Var.symbolic "" + LastPersonAge = Var.symbolic 0 } + + override _.Range = Range.linear 1 20 + + override _.Commands = [| + AddPersonCommand() + VerifyLastPersonCommand() + |] + +[] +let ``Var.map allows projecting fields from structured command outputs``() = + let sut = PersonRegistry() + VarMapSpec().ToProperty(sut).Check() + +[] +let ``Var.map preserves symbolic variable names``() = + // Create a symbolic var with a default person + let personVar = Var.symbolic { Name = "Alice"; Age = 30 } + + // Map to get name + let nameVar = Var.map (fun p -> p.Name) personVar + + // Both vars should have the same Name (symbolic vars have Name = -1) + Assert.Equal(-1, personVar.VarName) + Assert.Equal(-1, nameVar.VarName) + Assert.Equal(personVar.IsBounded, nameVar.IsBounded) + +[] +let ``Var.map chains multiple projections``() = + let personVar = Var.symbolic { Name = "Bob"; Age = 25 } + + // Chain projections + let nameVar = Var.map _.Name personVar + let nameLengthVar = Var.map String.length nameVar + + // All should share the same symbolic name + Assert.Equal(-1, nameLengthVar.VarName) + Assert.False(nameLengthVar.IsBounded)