diff --git a/Dummies.UnitTests/AnyNullableTests.cs b/Dummies.UnitTests/AnyNullableTests.cs new file mode 100644 index 00000000..625c3bec --- /dev/null +++ b/Dummies.UnitTests/AnyNullableTests.cs @@ -0,0 +1,103 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +public sealed class AnyNullableTests { + + #region Statics members declarations + + private const int SampleCount = 200; + + // Note: chaining OrNull twice (a nullable of a nullable) is a compile-time error — a Nullable is not a + // struct and not a class, so neither OrNull overload applies. That guard needs no runtime test. + + private static string Render(int? value) { + return value?.ToString() ?? "null"; + } + + #endregion + + [Fact(DisplayName = "OrNull on a value type yields both null and non-null; the non-null values honour the inner constraints.")] + public void ValueTypeOrNullYieldsBothCases() { + IAny generator = Any.Int32().Between(1, 100).OrNull(); + + int nulls = 0; + int nonNulls = 0; + for (int i = 0; i < SampleCount; i++) { + int? value = generator.Generate(); + if (value is null) { + nulls++; + } else { + nonNulls++; + Check.That(value.Value is >= 1 and <= 100).IsTrue(); + } + } + + Check.That(nulls).IsStrictlyGreaterThan(0); + Check.That(nonNulls).IsStrictlyGreaterThan(0); + } + + [Fact(DisplayName = "OrNull on a reference type yields both null and non-null values satisfying the inner constraints.")] + public void ReferenceTypeOrNullYieldsBothCases() { + IAny generator = Any.String().NonEmpty().OrNull(); + + bool sawNull = false; + bool sawNonNull = false; + for (int i = 0; i < SampleCount; i++) { + string? value = generator.Generate(); + if (value is null) { + sawNull = true; + } else { + sawNonNull = true; + Check.That(value).IsNotEmpty(); + } + } + + Check.That(sawNull).IsTrue(); + Check.That(sawNonNull).IsTrue(); + } + + [Fact(DisplayName = "OrNull is reproducible: two same-seed contexts replay the same null/value sequence.")] + public void OrNullIsReproducibleUnderASeed() { + IAny first = Any.WithSeed(123).Int32().OrNull(); + IAny second = Any.WithSeed(123).Int32().OrNull(); + + string sequenceOne = string.Join("|", Enumerable.Range(0, 30).Select(_ => Render(first.Generate()))); + string sequenceTwo = string.Join("|", Enumerable.Range(0, 30).Select(_ => Render(second.Generate()))); + + Check.That(sequenceTwo).IsEqualTo(sequenceOne); + // The sequence exercises both branches — otherwise the reproducibility guarantee would be vacuous. + Check.That(sequenceOne).Contains("null"); + } + + [Fact(DisplayName = "OrNull composes with As to produce an optional value object.")] + public void OrNullComposesWithAs() { + IAny generator = Any.String().StartingWith("ORD-").WithLength(12).As(OrderReference.Create).OrNull(); + + bool sawNull = false; + bool sawNonNull = false; + for (int i = 0; i < SampleCount; i++) { + OrderReference? reference = generator.Generate(); + if (reference is null) { + sawNull = true; + } else { + sawNonNull = true; + Check.That(reference.Value).StartsWith("ORD-"); + } + } + + Check.That(sawNull).IsTrue(); + Check.That(sawNonNull).IsTrue(); + } + + [Fact(DisplayName = "OrNull validates its argument on both the value-type and reference-type overloads.")] + public void OrNullValidatesItsArgument() { + Check.ThatCode(() => ((IAny)null!).OrNull()).Throws(); + Check.ThatCode(() => ((IAny)null!).OrNull()).Throws(); + } + +} diff --git a/Dummies.UnitTests/SeedReproducibilityTests.cs b/Dummies.UnitTests/SeedReproducibilityTests.cs index 0cc81ac9..caac4214 100644 --- a/Dummies.UnitTests/SeedReproducibilityTests.cs +++ b/Dummies.UnitTests/SeedReproducibilityTests.cs @@ -34,11 +34,13 @@ private static string Batch() { Half tiny = Any.Half(); List list = Any.ListOf(Any.Int32().Between(0, 9)).WithCount(4); HashSet set = Any.SetOf(Any.Int32().Between(0, 99)).WithCount(3); + int? maybe = Any.Int32().Between(0, 9).OrNull().Generate(); return string.Join("|", full, bounded, free, capped, shaped, wide, unsigned, real, exact, flag, id, letter, span.Ticks, instant.Ticks, huge, tiny, - string.Join("-", list), string.Join("-", set.OrderBy(value => value))); + string.Join("-", list), string.Join("-", set.OrderBy(value => value)), + maybe?.ToString() ?? "null"); } #endregion diff --git a/Dummies/NullableExtensions.cs b/Dummies/NullableExtensions.cs new file mode 100644 index 00000000..04587418 --- /dev/null +++ b/Dummies/NullableExtensions.cs @@ -0,0 +1,80 @@ +namespace Dummies; + +/// +/// Makes a value-type generator optionally null: turns an +/// into an of that yields +/// null on an even coin flip and, otherwise, a value satisfying the constraints declared upstream — the +/// dummy for an optional value-type field (int?, DateTime?, Guid?, an enum, ...). +/// +public static class NullableExtensions { + + /// + /// Derives a generator that yields null about half the time and, otherwise, a value drawn from + /// — so a test exercises both the present and the absent case without pinning + /// either. Reproducible under a seed, like every other draw. + /// + /// + /// + /// The null-versus-value decision draws from the same random context as the wrapped generator, so an + /// Any.Reproducibly(...) run replays it exactly. A null draw does not consume a value from + /// the wrapped generator. + /// + /// + /// + /// int? discount = Any.Int32().Between(0, 100).OrNull().Generate(); + /// + /// + /// + /// The generator of the non-null values. + /// The underlying value type. + /// A generator of . + /// Thrown when is null. + public static IAny OrNull(this IAny generator) + where T : struct { + if (generator is null) { throw new ArgumentNullException(nameof(generator)); } + + RandomSource? source = AnyDerivation.SourceOf(generator); + + return new DerivedAny(source, () => { + RandomSource working = source ?? AmbientRandomSource.Instance; + + return working.Current.Random.Next(2) == 0 ? (T?)null : generator.Generate(); + }); + } + +} + +/// +/// Makes a reference-type generator optionally null — the sibling of +/// for the reference-type case (a nullable string, or an optional +/// value object produced through As). It lives in its own class because a single overloaded +/// OrNull constrained once to struct and once to class would collide. +/// +public static class NullableReferenceExtensions { + + /// + /// Derives a generator that yields null about half the time and, otherwise, a value drawn from + /// — the dummy for an optional reference-type field. + /// + /// + /// The null-versus-value decision draws from the same random context as the wrapped generator, so a + /// reproducible run replays it exactly; a null draw does not consume a value from the wrapped generator. + /// + /// The generator of the non-null values. + /// The underlying reference type. + /// A generator that is sometimes null. + /// Thrown when is null. + public static IAny OrNull(this IAny generator) + where T : class { + if (generator is null) { throw new ArgumentNullException(nameof(generator)); } + + RandomSource? source = AnyDerivation.SourceOf(generator); + + return new DerivedAny(source, () => { + RandomSource working = source ?? AmbientRandomSource.Instance; + + return working.Current.Random.Next(2) == 0 ? (T?)null : generator.Generate(); + }); + } + +} diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 855ad6c1..66ae3d47 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -46,6 +46,9 @@ matter — and that is the point. `WithCount`/`NonEmpty`/`Distinct`/`Containing`. A distinct collection asked for more elements than its element domain can produce fails fast, just like any other conflict; `Any.PairOf`/`TripleOf` pair generators into value tuples. +- **Optional values**: `.OrNull()` turns any generator into one that is `null` about + half the time and otherwise a constrained value — the dummy for an optional field, + for value types (`int?`, `Guid?`, ...) and reference types alike. - **Reproducible runs**: wrap a test in `Any.Reproducibly(...)` and a failing run reports the seed to replay; `Any.WithSeed(seed)` gives an isolated, deterministic context.