diff --git a/Dummies.UnitTests/CrossEngineReachabilityTests.cs b/Dummies.UnitTests/CrossEngineReachabilityTests.cs new file mode 100644 index 0000000..ac2fc58 --- /dev/null +++ b/Dummies.UnitTests/CrossEngineReachabilityTests.cs @@ -0,0 +1,426 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +/// +/// A single scenario battery run against every interval-backed builder through a small per-type adapter, +/// so an engine-level regression cannot hide in the one type-facade a hand-written test happens not to cover. +/// Adding a builder means adding one row to , not a new file. +/// +/// +/// +/// The suite closes the reachability blind spot the 2026-07-20 Dummies architecture audit named (§9.2/§9.3, +/// issue #213): the existing tests assert membership (a generated value satisfies its constraints) but +/// never reachability (the whole declared domain is actually generable). Two shipped defects survived +/// that gap — never reaching the upper half of a range (#206) and the +/// AnySingle/AnyHalf exclusion nudge stalling on satisfiable specs (#207). Both are guarded here +/// structurally, across every engine at once, in addition to their dedicated regressions +/// ( and +/// , kept as focused, commented guards). +/// +/// +/// Every scenario is deterministic: one fixed seed, a fixed draw count large enough that a correct uniform +/// generator reaches the asserted region with overwhelming probability while a stuck one (half a range +/// unreachable) fails every time. No randomness leaks in, so the suite is a stable CI guard, never a flaky one. +/// +/// +public sealed class CrossEngineReachabilityTests { + + #region Statics members declarations + + // Three consecutive representable values of each floating type: a tight domain where excluding one endpoint + // must still generate by nudging along the type's own ladder — the exact shape that stalled #207 on the + // quantized types. Computed once via the type-aware bit step so the values are genuinely adjacent. + private static readonly double DLo = 1.0d; + private static readonly double DMid = Math.BitIncrement(1.0d); + private static readonly double DHi = Math.BitIncrement(Math.BitIncrement(1.0d)); + + private static readonly float FLo = 1.0f; + private static readonly float FMid = MathF.BitIncrement(1.0f); + private static readonly float FHi = MathF.BitIncrement(MathF.BitIncrement(1.0f)); + + private static readonly Half HLo = (Half)1; + private static readonly Half HMid = NextHalf((Half)1); + private static readonly Half HHi = NextHalf(NextHalf((Half)1)); + + // Temporal anchors — the low end of each narrow (few-ordinal) domain and of each wide range. + private static readonly DateTime DtAnchor = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTimeOffset DtoAnchor = new(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly DateOnly DoAnchor = new(2000, 1, 1); + private static readonly TimeOnly ToAnchor = new(9, 0, 0); + + private static readonly Int128 WideInt128 = (Int128)ulong.MaxValue * 10; + private static readonly UInt128 WideUInt128 = (UInt128)ulong.MaxValue * 10; + + /// + /// One adapter per interval-backed builder — every discrete integer, the two 128-bit integers, the three + /// binary-floating types, , and the five temporal types. The non-interval scalars + /// (bool, guid, string, enum, char) are deliberately absent: they expose no user-bounded ordered range, so + /// "both halves / both endpoints" is undefined for them. + /// + private static readonly ReachabilityCase[] AllCases = { + Case("SByte", true, + c => c.SByte(), + (c, lo, hi) => c.SByte().Between(lo, hi), + (c, lo, hi, x) => c.SByte().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.SByte().OneOf(allow).Except(except), + v => v, sbyte.MinValue, sbyte.MaxValue, -100, 100, 1, 2, 3), + Case("Int16", true, + c => c.Int16(), + (c, lo, hi) => c.Int16().Between(lo, hi), + (c, lo, hi, x) => c.Int16().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.Int16().OneOf(allow).Except(except), + v => v, short.MinValue, short.MaxValue, -30000, 30000, 1, 2, 3), + Case("Int32", true, + c => c.Int32(), + (c, lo, hi) => c.Int32().Between(lo, hi), + (c, lo, hi, x) => c.Int32().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.Int32().OneOf(allow).Except(except), + v => v, int.MinValue, int.MaxValue, -1_000_000, 1_000_000, 1, 2, 3), + Case("Int64", true, + c => c.Int64(), + (c, lo, hi) => c.Int64().Between(lo, hi), + (c, lo, hi, x) => c.Int64().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.Int64().OneOf(allow).Except(except), + v => v, long.MinValue, long.MaxValue, -1_000_000_000_000L, 1_000_000_000_000L, 1L, 2L, 3L), + Case("Byte", true, + c => c.Byte(), + (c, lo, hi) => c.Byte().Between(lo, hi), + (c, lo, hi, x) => c.Byte().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.Byte().OneOf(allow).Except(except), + v => v, byte.MinValue, byte.MaxValue, 10, 240, 100, 101, 102), + Case("UInt16", true, + c => c.UInt16(), + (c, lo, hi) => c.UInt16().Between(lo, hi), + (c, lo, hi, x) => c.UInt16().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.UInt16().OneOf(allow).Except(except), + v => v, ushort.MinValue, ushort.MaxValue, 100, 60000, 1000, 1001, 1002), + Case("UInt32", true, + c => c.UInt32(), + (c, lo, hi) => c.UInt32().Between(lo, hi), + (c, lo, hi, x) => c.UInt32().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.UInt32().OneOf(allow).Except(except), + v => v, uint.MinValue, uint.MaxValue, 1000u, 4_000_000_000u, 100_000u, 100_001u, 100_002u), + Case("UInt64", true, + c => c.UInt64(), + (c, lo, hi) => c.UInt64().Between(lo, hi), + (c, lo, hi, x) => c.UInt64().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.UInt64().OneOf(allow).Except(except), + v => v, ulong.MinValue, ulong.MaxValue, 1000ul, 10_000_000_000_000_000_000ul, 1_000_000ul, 1_000_001ul, 1_000_002ul), + Case("Int128", true, + c => c.Int128(), + (c, lo, hi) => c.Int128().Between(lo, hi), + (c, lo, hi, x) => c.Int128().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.Int128().OneOf(allow).Except(except), + v => (double)v, Int128.MinValue, Int128.MaxValue, -WideInt128, WideInt128, Int128.Zero, Int128.One, (Int128)2), + Case("UInt128", true, + c => c.UInt128(), + (c, lo, hi) => c.UInt128().Between(lo, hi), + (c, lo, hi, x) => c.UInt128().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.UInt128().OneOf(allow).Except(except), + v => (double)v, UInt128.MinValue, UInt128.MaxValue, UInt128.Zero, WideUInt128, UInt128.Zero, UInt128.One, (UInt128)2), + Case("Double", false, + c => c.Double(), + (c, lo, hi) => c.Double().Between(lo, hi), + (c, lo, hi, x) => c.Double().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.Double().OneOf(allow).Except(except), + v => v, double.MinValue, double.MaxValue, -1_000_000d, 1_000_000d, DLo, DMid, DHi), + Case("Single", false, + c => c.Single(), + (c, lo, hi) => c.Single().Between(lo, hi), + (c, lo, hi, x) => c.Single().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.Single().OneOf(allow).Except(except), + v => v, float.MinValue, float.MaxValue, -100_000f, 100_000f, FLo, FMid, FHi), + Case("Half", false, + c => c.Half(), + (c, lo, hi) => c.Half().Between(lo, hi), + (c, lo, hi, x) => c.Half().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.Half().OneOf(allow).Except(except), + v => (double)v, Half.MinValue, Half.MaxValue, (Half)(-1000), (Half)1000, HLo, HMid, HHi), + Case("Decimal", false, + c => c.Decimal(), + (c, lo, hi) => c.Decimal().Between(lo, hi), + (c, lo, hi, x) => c.Decimal().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.Decimal().OneOf(allow).Except(except), + v => (double)v, decimal.MinValue, decimal.MaxValue, 0m, 1_000_000m, 1m, 2m, 3m), + Case("DateTime", true, + c => c.DateTime(), + (c, lo, hi) => c.DateTime().Between(lo, hi), + (c, lo, hi, x) => c.DateTime().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.DateTime().OneOf(allow).Except(except), + v => v.Ticks, DateTime.MinValue, DateTime.MaxValue, + DtAnchor, new DateTime(2100, 1, 1, 0, 0, 0, DateTimeKind.Utc), DtAnchor, DtAnchor.AddTicks(1), DtAnchor.AddTicks(2)), + Case("DateTimeOffset", true, + c => c.DateTimeOffset(), + (c, lo, hi) => c.DateTimeOffset().Between(lo, hi), + (c, lo, hi, x) => c.DateTimeOffset().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.DateTimeOffset().OneOf(allow).Except(except), + v => v.UtcTicks, DateTimeOffset.MinValue, DateTimeOffset.MaxValue, + DtoAnchor, new DateTimeOffset(2100, 1, 1, 0, 0, 0, TimeSpan.Zero), DtoAnchor, DtoAnchor.AddTicks(1), DtoAnchor.AddTicks(2)), + Case("DateOnly", true, + c => c.DateOnly(), + (c, lo, hi) => c.DateOnly().Between(lo, hi), + (c, lo, hi, x) => c.DateOnly().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.DateOnly().OneOf(allow).Except(except), + v => v.DayNumber, DateOnly.MinValue, DateOnly.MaxValue, + DoAnchor, new DateOnly(2100, 1, 1), DoAnchor, DoAnchor.AddDays(1), DoAnchor.AddDays(2)), + Case("TimeOnly", true, + c => c.TimeOnly(), + (c, lo, hi) => c.TimeOnly().Between(lo, hi), + (c, lo, hi, x) => c.TimeOnly().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.TimeOnly().OneOf(allow).Except(except), + v => v.Ticks, TimeOnly.MinValue, TimeOnly.MaxValue, + new TimeOnly(0, 0, 0), new TimeOnly(23, 59, 59), ToAnchor, ToAnchor.Add(TimeSpan.FromTicks(1)), ToAnchor.Add(TimeSpan.FromTicks(2))), + Case("TimeSpan", true, + c => c.TimeSpan(), + (c, lo, hi) => c.TimeSpan().Between(lo, hi), + (c, lo, hi, x) => c.TimeSpan().Between(lo, hi).DifferentFrom(x), + (c, allow, except) => c.TimeSpan().OneOf(allow).Except(except), + v => v.Ticks, TimeSpan.MinValue, TimeSpan.MaxValue, + TimeSpan.FromHours(-1), TimeSpan.FromHours(1), TimeSpan.Zero, TimeSpan.FromTicks(1), TimeSpan.FromTicks(2)), + }; + + private static readonly IReadOnlyDictionary ByName = AllCases.ToDictionary(one => one.Name); + + /// The builder names, one theory row each — a serializable key so every builder is an isolated test. + public static TheoryData CaseNames() { + TheoryData data = new(); + foreach (ReachabilityCase one in AllCases) { data.Add(one.Name); } + + return data; + } + + private static IntervalCase Case(string name, bool exact, + Func> full, + Func> between, + Func> betweenDifferentFrom, + Func> oneOfExcept, + Func scale, + T domainMin, T domainMax, T wideLo, T wideHi, T na, T nb, T nc) { + return new IntervalCase(name, exact, full, between, betweenDifferentFrom, oneOfExcept, scale, + domainMin, domainMax, wideLo, wideHi, na, nb, nc); + } + + private static Half NextHalf(Half value) { + return BitConverter.Int16BitsToHalf((short)(BitConverter.HalfToInt16Bits(value) + 1)); + } + + #endregion + + [Theory(DisplayName = "Full range: an unconstrained generator reaches both halves of its domain.")] + [MemberData(nameof(CaseNames))] + public void FullRangeReachesBothHalvesOfTheDomain(string builder) { + ByName[builder].FullRangeReachesBothHalvesOfTheDomain(); + } + + [Theory(DisplayName = "Between: a wide range reaches both halves — neither half is silently unreachable.")] + [MemberData(nameof(CaseNames))] + public void WideBetweenReachesBothHalves(string builder) { + ByName[builder].WideBetweenReachesBothHalves(); + } + + [Theory(DisplayName = "Between: both inclusive bounds are reachable (exactly for discrete types, to within 1% for continuous ones).")] + [MemberData(nameof(CaseNames))] + public void BothInclusiveBoundsAreReachable(string builder) { + ByName[builder].BothInclusiveBoundsAreReachable(); + } + + [Theory(DisplayName = "Exclusion: a point excluded from a narrow range still generates and is never returned.")] + [MemberData(nameof(CaseNames))] + public void NarrowExclusionStillGenerates(string builder) { + ByName[builder].NarrowExclusionStillGenerates(); + } + + [Theory(DisplayName = "OneOf then Except: only the surviving allowed values are generated.")] + [MemberData(nameof(CaseNames))] + public void OneOfThenExceptYieldsOnlyTheSurvivors(string builder) { + ByName[builder].OneOfThenExceptYieldsOnlyTheSurvivors(); + } + + [Theory(DisplayName = "Conflict: contradictory constraints throw naming both sides.")] + [MemberData(nameof(CaseNames))] + public void ContradictoryConstraintsNameBothSides(string builder) { + ByName[builder].ContradictoryConstraintsNameBothSides(); + } + +} + +/// One interval-backed builder's participation in the shared scenario battery — the per-type adapter. +internal abstract class ReachabilityCase { + + protected ReachabilityCase(string name) { + Name = name; + } + + public string Name { get; } + + public sealed override string ToString() { + return Name; + } + + public abstract void FullRangeReachesBothHalvesOfTheDomain(); + + public abstract void WideBetweenReachesBothHalves(); + + public abstract void BothInclusiveBoundsAreReachable(); + + public abstract void NarrowExclusionStillGenerates(); + + public abstract void OneOfThenExceptYieldsOnlyTheSurvivors(); + + public abstract void ContradictoryConstraintsNameBothSides(); + +} + +/// +/// The generic adapter carrying a builder's value type , its wide and narrow test +/// domains, whether its endpoints are exactly reachable, a monotone projection onto for +/// half/bound detection, and the four uniformly-named chains the scenarios drive (Between, +/// Between + DifferentFrom, OneOf + Except, and the unconstrained builder). +/// +internal sealed class IntervalCase : ReachabilityCase { + + private const int Seed = 20260721; + private const int DistributionSamples = 4000; + private const int SetSamples = 200; + + #region Fields declarations + + private readonly Func> _betweenDifferentFrom; + private readonly Func> _between; + private readonly T _domainMax; + private readonly T _domainMin; + private readonly bool _endpointsExact; + private readonly Func> _full; + private readonly T _na; + private readonly T _nb; + private readonly T _nc; + private readonly Func> _oneOfExcept; + private readonly Func _scale; + private readonly T _wideHi; + private readonly T _wideLo; + + #endregion + + public IntervalCase(string name, bool endpointsExact, + Func> full, + Func> between, + Func> betweenDifferentFrom, + Func> oneOfExcept, + Func scale, + T domainMin, T domainMax, T wideLo, T wideHi, T na, T nb, T nc) + : base(name) { + _endpointsExact = endpointsExact; + _full = full; + _between = between; + _betweenDifferentFrom = betweenDifferentFrom; + _oneOfExcept = oneOfExcept; + _scale = scale; + _domainMin = domainMin; + _domainMax = domainMax; + _wideLo = wideLo; + _wideHi = wideHi; + _na = na; + _nb = nb; + _nc = nc; + } + + public override void FullRangeReachesBothHalvesOfTheDomain() { + (double min, double max, _) = Sample(_full(Any.WithSeed(Seed)), DistributionSamples); + double mid = _scale(_domainMin) / 2 + _scale(_domainMax) / 2; + + Check.That(min).IsStrictlyLessThan(mid); // the lower half of the domain is reached + Check.That(max).IsStrictlyGreaterThan(mid); // and so is the upper half + } + + public override void WideBetweenReachesBothHalves() { + (double min, double max, _) = Sample(_between(Any.WithSeed(Seed), _wideLo, _wideHi), DistributionSamples); + double lo = _scale(_wideLo); + double hi = _scale(_wideHi); + double mid = lo / 2 + hi / 2; + + Check.That(min).IsGreaterOrEqualThan(lo); // draws stay in range + Check.That(max).IsLessOrEqualThan(hi); + Check.That(min).IsStrictlyLessThan(mid); // the lower half is reached + Check.That(max).IsStrictlyGreaterThan(mid); // the upper half is reached — the #206 class of defect + } + + public override void BothInclusiveBoundsAreReachable() { + if (_endpointsExact) { + // Discrete types: a narrow three-value range must hand back both of its exact endpoints. + (_, _, HashSet seen) = Sample(_between(Any.WithSeed(Seed), _na, _nc), DistributionSamples); + Check.That(seen.Contains(_na)).IsTrue(); + Check.That(seen.Contains(_nc)).IsTrue(); + + return; + } + + // Continuous types: exact endpoints are a measure-zero target, so a draw must instead come within 1% of + // each inclusive bound of a wide range. A generator stuck below the midpoint (#206) never gets near the top. + (double min, double max, _) = Sample(_between(Any.WithSeed(Seed), _wideLo, _wideHi), DistributionSamples); + double lo = _scale(_wideLo); + double hi = _scale(_wideHi); + double range = hi - lo; + + Check.That(max).IsStrictlyGreaterThan(hi - range * 0.01d); + Check.That(min).IsStrictlyLessThan(lo + range * 0.01d); + } + + public override void NarrowExclusionStillGenerates() { + // Between(na, nc).DifferentFrom(na): on the quantized floating types this drives the type-aware nudge that + // stalled in #207; on the discrete types it drives the ordinal exclusion. Either way it must generate. + IAny generator = _betweenDifferentFrom(Any.WithSeed(Seed), _na, _nc, _na); + EqualityComparer equals = EqualityComparer.Default; + double lo = _scale(_na); + double hi = _scale(_nc); + + for (int i = 0; i < SetSamples; i++) { + T value = generator.Generate(); + Check.That(equals.Equals(value, _na)).IsFalse(); + Check.That(_scale(value)).IsGreaterOrEqualThan(lo); + Check.That(_scale(value)).IsLessOrEqualThan(hi); + } + } + + public override void OneOfThenExceptYieldsOnlyTheSurvivors() { + IAny generator = _oneOfExcept(Any.WithSeed(Seed), [_na, _nb, _nc], [_nb]); + EqualityComparer equals = EqualityComparer.Default; + + for (int i = 0; i < SetSamples; i++) { + T value = generator.Generate(); + Check.That(equals.Equals(value, _nb)).IsFalse(); + Check.That(equals.Equals(value, _na) || equals.Equals(value, _nc)).IsTrue(); + } + } + + public override void ContradictoryConstraintsNameBothSides() { + // OneOf(na).Except(na) empties the allow-list; the message must name both the allow-list and the exclusion. + // Asserting the method-name tokens keeps this independent of how each type renders its values. + ConflictingAnyConstraintException conflict = Assert.Throws( + () => _oneOfExcept(Any.WithSeed(Seed), [_na], [_na])); + + Check.That(conflict.Message).Contains("OneOf("); + Check.That(conflict.Message).Contains("Except("); + } + + private (double Min, double Max, HashSet Seen) Sample(IAny generator, int count) { + double min = double.PositiveInfinity; + double max = double.NegativeInfinity; + HashSet seen = new(); + + for (int i = 0; i < count; i++) { + T value = generator.Generate(); + double scaled = _scale(value); + seen.Add(value); + if (scaled < min) { min = scaled; } + if (scaled > max) { max = scaled; } + } + + return (min, max, seen); + } + +} diff --git a/Dummies/DecimalIntervalSpec.cs b/Dummies/DecimalIntervalSpec.cs index 5cef143..894973a 100644 --- a/Dummies/DecimalIntervalSpec.cs +++ b/Dummies/DecimalIntervalSpec.cs @@ -151,10 +151,11 @@ internal decimal Generate(Random random, int seed) { BitConverter.ToInt32(mantissa, 4), BitConverter.ToInt32(mantissa, 8), false, 28) / MaxFraction; - // Sample around the midpoint so the span (max - min) never overflows on wide ranges. - decimal mid = _min / 2 + _max / 2; - decimal half = _max / 2 - _min / 2; - decimal candidate = Clamped(mid + (fraction * 2 - 1) * half); + // Interpolate as a convex combination: min*(1 - fraction) + max*fraction stays within [min, max] for + // fraction in [0, 1], and no intermediate ever leaves the decimal range. The earlier midpoint form + // (mid ± half) overflowed on the full domain — it is symmetric, so max/2 rounds up and half = max/2 - min/2 + // doubles to just past decimal.MaxValue, throwing on an unconstrained Any.Decimal().Generate(). + decimal candidate = Clamped(_min * (1m - fraction) + _max * fraction); // A draw colliding with an excluded point is walked by the smallest decimal step — deterministic and // bounded, not a retry loop. (At extreme magnitudes the step can vanish in rounding; the budget then