diff --git a/JustDummies.PropertyTests/EnumCombinationProperties.cs b/JustDummies.PropertyTests/EnumCombinationProperties.cs new file mode 100644 index 00000000..c90457a0 --- /dev/null +++ b/JustDummies.PropertyTests/EnumCombinationProperties.cs @@ -0,0 +1,128 @@ +#region Usings declarations + +using FsCheck; +using FsCheck.Fluent; + +using JetBrains.Annotations; + +#endregion + +namespace JustDummies.PropertyTests; + +/// +/// Property-based tests for . Where the example-based suite pins +/// the universe a handful of named enum shapes yield, these quantify over the constraints applied on top of +/// it: the allow-list and the exclusion set are drawn from the universe itself, so a draw escaping the universe, an +/// exclusion silently read as a bit mask, or a pool that empties without conflicting is found and shrunk. +/// +/// +/// The universe is fixed per enum type, so it is the constraint sets — not the type — that carry the input space. +/// is used throughout because its four declared members give a universe of eight values: +/// small enough to enumerate in the assertion, wide enough that a subset drawn from it is rarely trivial. +/// +[TestSubject(typeof(AnyEnum<>))] +public sealed class EnumCombinationProperties { + + #region Statics members declarations + + /// Every value AllowingCombinations() must be able to draw for . + private static readonly Permissions[] Universe = Enumerable.Range(0, 8).Select(bits => (Permissions)bits).ToArray(); + + /// + /// A non-empty subset of the universe, in an arbitrary order and possibly with repetitions — an allow-list or + /// an exclusion set as a caller would write it. Repetitions are kept on purpose: Except and + /// OneOf both have to absorb a duplicate without changing the pool they compute. + /// + private static Gen Subsets() { + return Gen.NonEmptyListOf(Gen.Elements(Universe)).Select(values => values.ToArray()); + } + + #endregion + + [Fact(DisplayName = "AllowingCombinations: every draw is a combination of declared members, for every exclusion set.")] + public void EveryDrawStaysInTheUniverse() { + Prop.ForAll(Subsets().ToArbitrary(), + excluded => { + // A subset drawn with repetition can cover the whole universe; only then is a conflict owed, + // so the property branches on the drawn values rather than on the call shape. + Permissions[] distinct = excluded.Distinct().ToArray(); + if (distinct.Length == Universe.Length) { + return Expect.Throws( + () => Any.Enum().AllowingCombinations().Except(excluded)); + } + + return Expect.EveryDraw(Any.Enum().AllowingCombinations().Except(excluded), + value => Universe.Contains(value) && !distinct.Contains(value)); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "AllowingCombinations: exclusions compare by equality, never as a bit mask.")] + public void ExclusionsCompareByEquality() { + Prop.ForAll(Gen.Elements(Universe.Where(value => value != 0).ToArray()).ToArbitrary(), + excluded => { + // Every strict superset of the excluded value's bits is a DIFFERENT value, so it must remain + // reachable: reading Except as "no value carrying these bits" would make all of them vanish. + Permissions[] survivors = Universe.Where(value => value != excluded && (value & excluded) == excluded).ToArray(); + if (survivors.Length == 0) { return true; } + + List draws = Expect.Draws(Any.Enum().AllowingCombinations().Except(excluded), 200); + + return draws.All(value => value != excluded) && survivors.Any(draws.Contains); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "AllowingCombinations: an allow-list of combinations is honoured exactly, for every subset.")] + public void AnAllowListOfCombinationsIsHonoured() { + Prop.ForAll(Subsets().ToArbitrary(), + allowed => { + Permissions[] distinct = allowed.Distinct().ToArray(); + + return Expect.EveryDraw(Any.Enum().AllowingCombinations().OneOf(allowed), + distinct.Contains); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "AllowingCombinations: the declared-members default is untouched, for every exclusion set.")] + public void TheDefaultRemainsDeclaredMembersOnly() { + Permissions[] declared = [Permissions.None, Permissions.Read, Permissions.Write, Permissions.Exec]; + + Prop.ForAll(Gen.Choose(0, 2).ToArbitrary(), + size => { + Permissions[] excluded = declared.Take(size).ToArray(); + AnyEnum generator = excluded.Length == 0 + ? Any.Enum() + : Any.Enum().Except(excluded); + + // Without the opt-in no combination is ever drawn, whatever else was declared — the contract + // AllowingCombinations() exists precisely to leave alone. + return Expect.EveryDraw(generator, value => declared.Contains(value) && !excluded.Contains(value)); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "AllowingCombinations: two contexts on the same seed draw the same combinations, for every seed.")] + public void CombinationsAreReproducibleForEverySeed() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + List first = Expect.Draws(Any.WithSeed(seed).Enum().AllowingCombinations(), 12); + List second = Expect.Draws(Any.WithSeed(seed).Enum().AllowingCombinations(), 12); + + return first.SequenceEqual(second); + }) + .QuickCheckThrowOnFailure(); + } + + [Flags] + private enum Permissions { + + None = 0, + Read = 1, + Write = 2, + Exec = 4 + + } + +} diff --git a/JustDummies.UnitTests/AnyDateTimeOffsetOffsetTests.cs b/JustDummies.UnitTests/AnyDateTimeOffsetOffsetTests.cs index 91de606b..8cd6c087 100644 --- a/JustDummies.UnitTests/AnyDateTimeOffsetOffsetTests.cs +++ b/JustDummies.UnitTests/AnyDateTimeOffsetOffsetTests.cs @@ -69,6 +69,21 @@ public void WithOffsetArguments() { Check.ThatCode(() => Any.DateTimeOffset().WithOffsetBetween(TimeSpan.FromHours(2), TimeSpan.FromHours(-2))).Throws(); } + [Fact(DisplayName = "WithOffset: OneOf returns its values verbatim, offset included, in either order.")] + public void OneOfKeepsItsOwnOffset() { + // The accepted risk recorded in ADR-0037: OneOf is a terminal enumeration of exact values, so the offset + // dimension governs only the CONSTRUCTED draw and never rewrites a supplied value's own offset. Pinned here so + // the divergence stays a decision rather than becoming an unnoticed regression in either direction. + DateTimeOffset pinned = new(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); + TimeSpan requested = TimeSpan.FromHours(5); + + for (int i = 0; i < SampleCount; i++) { + Check.That(Any.DateTimeOffset().WithOffset(requested).OneOf(pinned).Generate()).IsEqualTo(pinned); + Check.That(Any.DateTimeOffset().WithOffset(requested).OneOf(pinned).Generate().Offset).IsEqualTo(pinned.Offset); + Check.That(Any.DateTimeOffset().OneOf(pinned).WithOffset(requested).Generate().Offset).IsEqualTo(pinned.Offset); + } + } + [Fact(DisplayName = "WithOffset: a second, different offset is rejected as already declared.")] public void WithOffsetDeclaredOnce() { Check.ThatCode(() => Any.DateTimeOffset().WithOffset(TimeSpan.FromHours(2)).WithOffset(TimeSpan.FromHours(3))) diff --git a/JustDummies.UnitTests/AnyEnumCombinationTests.cs b/JustDummies.UnitTests/AnyEnumCombinationTests.cs new file mode 100644 index 00000000..9f62675c --- /dev/null +++ b/JustDummies.UnitTests/AnyEnumCombinationTests.cs @@ -0,0 +1,207 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace JustDummies.UnitTests; + +/// +/// The named cases of : the exact universe a given enum shape +/// yields, the conflict wording, and the boundary between the declared-members default and the opt-in. The +/// universal half — that every draw belongs to the universe whatever the constraints — lives in +/// JustDummies.PropertyTests. +/// +public sealed class AnyEnumCombinationTests { + + // Enough draws that an eight-value universe is exhausted with overwhelming probability, while a missing value is + // not attributed to bad luck. The assertions below are on the SET observed, so they are reachability claims. + private const int SampleCount = 2000; + + [Flags] + private enum Permissions { + + None = 0, + Read = 1, + Write = 2, + Exec = 4 + + } + + // No zero member: the empty combination is not a value this enum defines. + [Flags] + private enum Sides { + + Left = 1, + Right = 2 + + } + + // A declared composite: ReadWrite is already Read | Write, so it must not widen the universe. + [Flags] + private enum Access { + + Read = 1, + Write = 2, + ReadWrite = 3 + + } + + private enum OrderStatus { + + Draft, + Validated, + Cancelled + + } + + [Fact(DisplayName = "A [Flags] enum still draws only declared members until combinations are allowed.")] + public void FlagsEnumDrawsDeclaredMembersByDefault() { + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { seen.Add(Any.Enum().Generate()); } + + // The contract the opt-in exists to leave untouched: the default never depends on the [Flags] attribute, so a + // combination is unreachable until the test asks for one. + Check.That(seen).IsOnlyMadeOf(Permissions.None, Permissions.Read, Permissions.Write, Permissions.Exec); + } + + [Fact(DisplayName = "AllowingCombinations: the universe is every combination, and the declared zero value.")] + public void CombinationsCoverTheWholeUniverse() { + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { seen.Add(Any.Enum().AllowingCombinations().Generate()); } + + Check.That(seen.Count).IsEqualTo(8); + for (int bits = 0; bits <= 7; bits++) { Check.That(seen).Contains((Permissions)bits); } + } + + [Fact(DisplayName = "AllowingCombinations: an enum declaring no zero member never yields the empty combination.")] + public void CombinationsOmitZeroWhenItIsNotDeclared() { + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { seen.Add(Any.Enum().AllowingCombinations().Generate()); } + + Check.That(seen).IsOnlyMadeOf(Sides.Left, Sides.Right, Sides.Left | Sides.Right); + } + + [Fact(DisplayName = "AllowingCombinations: a declared composite adds nothing — it already is a combination.")] + public void DeclaredCompositeDoesNotWidenTheUniverse() { + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { seen.Add(Any.Enum().AllowingCombinations().Generate()); } + + Check.That(seen).IsOnlyMadeOf(Access.Read, Access.Write, Access.ReadWrite); + } + + [Fact(DisplayName = "AllowingCombinations: applying it to an enum that is not [Flags] conflicts, naming why.")] + public void CombinationsRequireAFlagsEnum() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Enum().AllowingCombinations()); + + Check.That(conflict.Message).Contains("AllowingCombinations()"); + Check.That(conflict.Message).Contains("OrderStatus"); + Check.That(conflict.Message).Contains("[Flags]"); + } + + [Fact(DisplayName = "AllowingCombinations: applying it twice is a no-op, not a conflict.")] + public void CombinationsAreIdempotent() { + AnyEnum generator = Any.Enum().AllowingCombinations().AllowingCombinations(); + + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { seen.Add(generator.Generate()); } + + // Idempotent, not cumulative: the universe is the same eight values a single application yields. + Check.That(seen.Count).IsEqualTo(8); + } + + [Fact(DisplayName = "OneOf: a combination is refused before the opt-in, and the message names the missing one.")] + public void OneOfRefusesACombinationBeforeTheOptIn() { + ArgumentException error = Assert.Throws( + () => Any.Enum().OneOf(Permissions.Read | Permissions.Write)); + + Check.That(error.Message).Contains("AllowingCombinations()"); + } + + [Fact(DisplayName = "OneOf: a combination is accepted once combinations are allowed.")] + public void OneOfAcceptsACombinationAfterTheOptIn() { + AnyEnum generator = Any.Enum() + .AllowingCombinations() + .OneOf(Permissions.Read | Permissions.Write, Permissions.Exec); + + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { seen.Add(generator.Generate()); } + + Check.That(seen).IsOnlyMadeOf(Permissions.Read | Permissions.Write, Permissions.Exec); + } + + [Fact(DisplayName = "Except: exclusions compare by equality, so a combination carrying an excluded bit survives.")] + public void ExclusionsCompareByEquality() { + AnyEnum generator = Any.Enum().AllowingCombinations().Except(Permissions.Read); + + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { seen.Add(generator.Generate()); } + + // Read itself is gone; Read | Write is a different value and stays drawable — Except is not a bit mask. + Check.That(seen).Not.Contains(Permissions.Read); + Check.That(seen).Contains(Permissions.Read | Permissions.Write); + Check.That(seen.Count).IsEqualTo(7); + } + + [Fact(DisplayName = "AllowingCombinations: the widened universe feeds the distinct-collection cardinality check.")] + public void CombinationsWidenTheCardinalityHint() { + // Eight distinct values exist, so eight are obtainable and nine conflict eagerly — the same check that caps a + // declared-members draw at four. + HashSet eight = Any.SetOf(Any.Enum().AllowingCombinations()).WithCount(8).Generate(); + Check.That(eight.Count).IsEqualTo(8); + + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.SetOf(Any.Enum().AllowingCombinations()).WithCount(9).Generate()); + Check.That(conflict.Message).Contains("9"); + + ConflictingAnyConstraintException capped = Assert.Throws( + () => Any.SetOf(Any.Enum()).WithCount(5).Generate()); + Check.That(capped.Message).Contains("5"); + } + + [Fact(DisplayName = "AllowingCombinations: excluding the whole universe conflicts, naming both sides.")] + public void ExcludingTheWholeUniverseConflicts() { + Permissions[] everything = Enumerable.Range(0, 8).Select(bits => (Permissions)bits).ToArray(); + + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Enum().AllowingCombinations().Except(everything)); + + Check.That(conflict.Message).Contains("Except("); + Check.That(conflict.Message).Contains("Permissions"); + } + + [Fact(DisplayName = "AllowingCombinations: an enum with too many members to enumerate is refused, naming the ceiling.")] + public void TooManyMembersIsRefused() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Enum().AllowingCombinations()); + + Check.That(conflict.Message).Contains("AllowingCombinations()"); + Check.That(conflict.Message).Contains("21"); + Check.That(conflict.Message).Contains("20"); + Check.That(conflict.Message).Contains("OneOf"); + } + + [Fact(DisplayName = "AllowingCombinations: a seeded context replays the same combinations.")] + public void CombinationsReplayUnderASeed() { + List Batch(int seed) { + AnyContext context = Any.WithSeed(seed); + AnyEnum generator = context.Enum().AllowingCombinations(); + + return Enumerable.Range(0, 20).Select(_ => generator.Generate()).ToList(); + } + + Check.That(Batch(4242)).ContainsExactly(Batch(4242)); + } + + // Twenty-one single-bit members: one past the ceiling AllowingCombinations() will enumerate. + [Flags] + private enum Wide { + + B00 = 1 << 0, B01 = 1 << 1, B02 = 1 << 2, B03 = 1 << 3, B04 = 1 << 4, B05 = 1 << 5, B06 = 1 << 6, + B07 = 1 << 7, B08 = 1 << 8, B09 = 1 << 9, B10 = 1 << 10, B11 = 1 << 11, B12 = 1 << 12, B13 = 1 << 13, + B14 = 1 << 14, B15 = 1 << 15, B16 = 1 << 16, B17 = 1 << 17, B18 = 1 << 18, B19 = 1 << 19, B20 = 1 << 20 + + } + +} diff --git a/JustDummies.UnitTests/SurfaceParityTests.cs b/JustDummies.UnitTests/SurfaceParityTests.cs index 76f96f1d..b391e632 100644 --- a/JustDummies.UnitTests/SurfaceParityTests.cs +++ b/JustDummies.UnitTests/SurfaceParityTests.cs @@ -173,7 +173,9 @@ public static IEnumerable Builders() { // The remaining scalar builders each carry their own deliberate set. yield return [typeof(AnyBoolean), new[] { "True", "False", "DifferentFrom" }]; yield return [typeof(AnyGuid), new[] { "NonEmpty", "Empty", "OneOf", "Except", "DifferentFrom" }]; - yield return [typeof(AnyEnum), new[] { "OneOf", "Except", "DifferentFrom" }]; + // AnyEnum adds AllowingCombinations, the opt-in widening the draw from the declared members to their + // combinations — meaningful only for a [Flags] enum, hence a constraint rather than a second factory. + yield return [typeof(AnyEnum), new[] { "AllowingCombinations", "OneOf", "Except", "DifferentFrom" }]; yield return [typeof(AnyChar), new[] { "Alpha", "AlphaNumeric", "Numeric", "UpperCase", "LowerCase", "OneOf", "Except", "DifferentFrom" }]; // AnyString carries the exclusion pair Except/DifferentFrom (met by a bounded redraw, since strings are not diff --git a/JustDummies/AnyEnum.cs b/JustDummies/AnyEnum.cs index 794e8fb3..f05f07bc 100644 --- a/JustDummies/AnyEnum.cs +++ b/JustDummies/AnyEnum.cs @@ -1,3 +1,9 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + namespace JustDummies; /// @@ -6,28 +12,98 @@ namespace JustDummies; /// (, , ), and a combination that empties it /// fails eagerly with a naming both sides. /// +/// +/// A [Flags] enum declares bits meant to be combined, so its valid values +/// are the combinations, not only the declared members: Read | Write is a legitimate value the type never +/// declares. The declared-members default holds for those enums too — it is the only default valid for both enum +/// families, and switching on the attribute would make the draw depend on a type's metadata rather than on what +/// the test wrote. Opt in explicitly with to widen the draw to every +/// combination. +/// /// The enum type to draw values from. public sealed class AnyEnum : IAny, IHasRandomSource, ICardinalityHint where TEnum : struct, Enum { + // The ceiling on the number of non-zero declared members AllowingCombinations() will enumerate. The universe is + // materialized so the draw is exactly uniform over the DISTINCT values and the cardinality hint stays exact + // (a per-member coin flip is neither: with a declared composite such as ReadWrite = Read | Write, several + // subsets collapse onto the same value). Enumeration is 2^k, so it needs a bound; beyond it the constraint is + // refused by name rather than silently degraded into a second, non-uniform regime. + private const int MaxCombinableMembers = 20; + #region Statics members declarations - // The declared-members set of an enum type is a process constant; cached once per closed generic type - // instead of reflecting on every Any.Enum() call. + // The declared-members set and the [Flags] marking of an enum type are process constants; cached once per closed + // generic type instead of reflecting on every Any.Enum() call. private static readonly TEnum[] Declared = ((TEnum[])Enum.GetValues(typeof(TEnum))).Distinct().ToArray(); + private static readonly bool IsFlags = typeof(TEnum).IsDefined(typeof(FlagsAttribute), false); + + // The combination universe is a process constant too, but far more expensive than Declared, so it is built on + // first use instead of on every closed generic type. A race computes it twice and stores the same set: benign. + private static TEnum[]? _combinations; internal static AnyEnum Create(RandomSource source) { if (Declared.Length == 0) { throw new AnyGenerationException($"Cannot generate an arbitrary {typeof(TEnum).Name} value because the enum declares no members."); } - return new AnyEnum(source, Declared, null, null, []); + return new AnyEnum(source, Declared, false, null, null, []); + } + + /// + /// Every value obtained by OR-ing a non-empty subset of the declared members, plus the zero value when a zero + /// member is declared. Taking the declared members as the generating set — rather than the individual bits — + /// absorbs declared composites (ReadWrite = Read | Write contributes nothing new) without having to + /// decide which members "are" bits, and never invents the zero value for an enum that deliberately declares no + /// None. + /// + private static TEnum[] Combinations { + get { + if (_combinations is not null) { return _combinations; } + + ulong[] generators = Declared.Select(ToUInt64).Where(bits => bits != 0UL).ToArray(); + HashSet reachable = new(); + foreach (ulong generator in generators) { + // Union of what was reachable, what becomes reachable by adding this generator to it, and the + // generator alone — the OR-closure, built without enumerating the 2^k subsets that collapse. + foreach (ulong existing in reachable.ToArray()) { reachable.Add(existing | generator); } + reachable.Add(generator); + } + + // The empty subset ORs to zero, but that value belongs to the universe only when the enum defines it. + if (Declared.Any(value => ToUInt64(value) == 0UL)) { reachable.Add(0UL); } + + _combinations = reachable.OrderBy(bits => bits).Select(ToEnum).ToArray(); + + return _combinations; + } + } + + /// The value's underlying bits, whatever the enum's underlying type — signed members included. + private static ulong ToUInt64(TEnum value) { + // Convert.ToUInt64 throws on a negative signed member, so each signed width is read at its own size and + // reinterpreted, exactly as the runtime stores it. + return Type.GetTypeCode(typeof(TEnum)) switch { + TypeCode.SByte => unchecked((ulong)Convert.ToSByte(value, CultureInfo.InvariantCulture)), + TypeCode.Int16 => unchecked((ulong)Convert.ToInt16(value, CultureInfo.InvariantCulture)), + TypeCode.Int32 => unchecked((ulong)Convert.ToInt32(value, CultureInfo.InvariantCulture)), + TypeCode.Int64 => unchecked((ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture)), + _ => Convert.ToUInt64(value, CultureInfo.InvariantCulture) + }; + } + + private static TEnum ToEnum(ulong bits) { + return (TEnum)Enum.ToObject(typeof(TEnum), bits); } private static string V(TEnum value) { return value.ToString(); } + private static string V(int value) { + return value.ToString(CultureInfo.InvariantCulture); + } + private static string Join(TEnum[] values) { return string.Join(", ", values.Select(V)); } @@ -38,53 +114,108 @@ private static string Join(TEnum[] values) { private readonly IReadOnlyList? _allowed; private readonly string? _allowedConstraint; - private readonly IReadOnlyList _declared; + private readonly bool _combinable; private readonly IReadOnlyList _excluded; private readonly List _pool; private readonly RandomSource _source; + private readonly IReadOnlyList _universe; #endregion - private AnyEnum(RandomSource source, IReadOnlyList declared, + private AnyEnum(RandomSource source, IReadOnlyList universe, bool combinable, IReadOnlyList? allowed, string? allowedConstraint, IReadOnlyList excluded) { _source = source; - _declared = declared; + _universe = universe; + _combinable = combinable; _allowed = allowed; _allowedConstraint = allowedConstraint; _excluded = excluded; // Materialized once here — "constrain once, draw many": Generate never refilters the pool. - _pool = (allowed ?? declared).Where(value => !excluded.Contains(value)).ToList(); + _pool = (allowed ?? universe).Where(value => !excluded.Contains(value)).ToList(); } RandomSource? IHasRandomSource.Source => _source; - // The pool is materialized once at construction, so its size is the exact number of members drawable. + // The pool is materialized once at construction, so its size is the exact number of values drawable. long? ICardinalityHint.DistinctCardinality => _pool.Count; // The pool is the exact draw set, so membership is a direct pool lookup. bool ICardinalityHint.Contains(TEnum value) => _pool.Contains(value); + /// + /// Widens the draw from the declared members to every combination of them — the values a + /// [Flags] enum is designed to hold. Without it, a flags dummy carries at + /// most one bit and a branch reading two never runs. + /// + /// + /// + /// The universe is every value obtained by OR-ing a non-empty subset of the declared members, plus the + /// zero value when a zero member is declared: { None = 0, Read = 1, Write = 2, Exec = 4 } yields the + /// eight values 07, while { Left = 1, Right = 2 } yields only 1, 2 and + /// 3 — never 0, which that enum does not define. A declared composite adds nothing: + /// ReadWrite = Read | Write is already the combination of the two. + /// + /// + /// and keep comparing by equality, here as + /// everywhere else: Except(Read) forbids the value Read and still allows + /// Read | Write. Applied after , this constraint changes nothing — an explicit + /// allow-list is a terminal enumeration of exact values, so declare it before OneOf when the + /// allow-list itself names combinations. + /// + /// + /// A new generator drawing from the combination universe. + /// + /// Thrown when is not declared [Flags], when it declares more non-zero + /// members than the enumerable ceiling, or when the constraint contradicts a constraint already declared. + /// + public AnyEnum AllowingCombinations() { + const string constraint = "AllowingCombinations()"; + if (_combinable) { return this; } + + if (!IsFlags) { + throw new ConflictingAnyConstraintException( + $"Cannot apply {constraint} because {typeof(TEnum).Name} is not declared [Flags]: OR-ing its members would produce values the type does not define."); + } + + int generators = Declared.Count(value => ToUInt64(value) != 0UL); + if (generators > MaxCombinableMembers) { + throw new ConflictingAnyConstraintException( + $"Cannot apply {constraint} because {typeof(TEnum).Name} declares {V(generators)} non-zero members, more than the {V(MaxCombinableMembers)} whose combinations can be enumerated. " + + $"Draw from an explicit set with OneOf(...) instead."); + } + + return Validated(new AnyEnum(_source, Combinations, true, _allowed, _allowedConstraint, _excluded), constraint); + } + /// Requires the value to be one of the supplied members. Declared once per generator. - /// The allowed members; duplicates are ignored. Every value must be a declared member of — the generator never yields undeclared numeric values, not even explicitly supplied ones. + /// + /// The allowed values; duplicates are ignored. Every value must belong to the generator's universe — the + /// declared members, or every combination of them once has been applied. + /// The generator never yields a value outside that universe, not even an explicitly supplied one. + /// /// A new generator carrying the added constraint. /// Thrown when is null. - /// Thrown when is empty or contains a value that is not a declared member. + /// Thrown when is empty or contains a value outside the generator's universe. /// Thrown when the constraint contradicts a constraint already declared. public AnyEnum OneOf(params TEnum[] values) { if (values is null) { throw new ArgumentNullException(nameof(values)); } if (values.Length == 0) { throw new ArgumentException("At least one value is required.", nameof(values)); } foreach (TEnum value in values) { - if (!_declared.Contains(value)) { throw new ArgumentException($"The value {value} is not a declared member of {typeof(TEnum).Name}: the generator only ever yields declared members.", nameof(values)); } + if (!_universe.Contains(value)) { throw new ArgumentException($"The value {value} {DescribeOutsideUniverse()}", nameof(values)); } } string constraint = $"OneOf({Join(values)})"; if (_allowedConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {constraint} because {_allowedConstraint} is already defined."); } - return Validated(new AnyEnum(_source, _declared, values.Distinct().ToArray(), constraint, _excluded), constraint); + return Validated(new AnyEnum(_source, _universe, _combinable, values.Distinct().ToArray(), constraint, _excluded), constraint); } - /// Requires the value to be none of the supplied members. - /// The forbidden members. + /// + /// Requires the value to be none of the supplied ones, compared by equality — under + /// too, so Except(Read) forbids Read and still allows + /// Read | Write. + /// + /// The forbidden values. /// A new generator carrying the added constraint. /// Thrown when is null. /// Thrown when is empty. @@ -100,7 +231,7 @@ public AnyEnum Except(params TEnum[] values) { /// Requires the value to differ from — typically an existing value the test already /// holds. Semantically equivalent to ; the name carries the intent at the call site. /// - /// The member the generated value must differ from. + /// The value the generated value must differ from. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. public AnyEnum DifferentFrom(TEnum value) { @@ -112,19 +243,32 @@ public TEnum Generate() { return _pool[_source.Current.Random.Next(_pool.Count)]; } + /// + /// Why a supplied value is outside the universe — naming when the value is + /// a flag combination, since that is the constraint the caller is missing rather than a mistyped member. + /// + private string DescribeOutsideUniverse() { + string subject = $"is not a declared member of {typeof(TEnum).Name}: the generator only ever yields declared members."; + if (_combinable || !IsFlags) { return subject; } + + return $"{subject} Apply AllowingCombinations() first to draw combinations of them."; + } + private AnyEnum WithExcluded(TEnum[] values, string applying) { List excluded = new(_excluded); excluded.AddRange(values); - return Validated(new AnyEnum(_source, _declared, _allowed, _allowedConstraint, excluded), applying); + return Validated(new AnyEnum(_source, _universe, _combinable, _allowed, _allowedConstraint, excluded), applying); } private AnyEnum Validated(AnyEnum candidate, string applying) { if (candidate._pool.Count > 0) { return candidate; } - string pool = candidate._allowedConstraint is null - ? $"no declared {typeof(TEnum).Name} member remains available" - : $"no value {candidate._allowedConstraint} allows remains available"; + string pool = candidate._allowedConstraint is not null + ? $"no value {candidate._allowedConstraint} allows remains available" + : candidate._combinable + ? $"no {typeof(TEnum).Name} combination remains available" + : $"no declared {typeof(TEnum).Name} member remains available"; throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {pool}."); } diff --git a/JustDummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/JustDummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 37f1d562..e14b7250 100644 --- a/JustDummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/JustDummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -148,6 +148,7 @@ JustDummies.AnyDouble.OneOf(params double[]! values) -> JustDummies.AnyDouble! JustDummies.AnyDouble.Positive() -> JustDummies.AnyDouble! JustDummies.AnyDouble.Zero() -> JustDummies.AnyDouble! JustDummies.AnyEnum +JustDummies.AnyEnum.AllowingCombinations() -> JustDummies.AnyEnum! JustDummies.AnyEnum.DifferentFrom(TEnum value) -> JustDummies.AnyEnum! JustDummies.AnyEnum.Except(params TEnum[]! values) -> JustDummies.AnyEnum! JustDummies.AnyEnum.Generate() -> TEnum diff --git a/JustDummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/JustDummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 1f426bd1..acd4e340 100644 --- a/JustDummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/JustDummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -133,6 +133,7 @@ JustDummies.AnyDouble.OneOf(params double[]! values) -> JustDummies.AnyDouble! JustDummies.AnyDouble.Positive() -> JustDummies.AnyDouble! JustDummies.AnyDouble.Zero() -> JustDummies.AnyDouble! JustDummies.AnyEnum +JustDummies.AnyEnum.AllowingCombinations() -> JustDummies.AnyEnum! JustDummies.AnyEnum.DifferentFrom(TEnum value) -> JustDummies.AnyEnum! JustDummies.AnyEnum.Except(params TEnum[]! values) -> JustDummies.AnyEnum! JustDummies.AnyEnum.Generate() -> TEnum diff --git a/JustDummies/README.nuget.md b/JustDummies/README.nuget.md index d659a263..a936403b 100644 --- a/JustDummies/README.nuget.md +++ b/JustDummies/README.nuget.md @@ -28,7 +28,8 @@ matter — and that is the point. `.Generate()`, across the .NET simple types: `String`, `Char`, every integer width (`SByte`/`Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Int64`/`UInt64`), `Double`/`Single`/`Decimal` (finite values only — never NaN or infinities), - `Boolean`, `Guid`, `Enum` (declared members only), `TimeSpan`, `DateTime` (UTC) + `Boolean`, `Guid`, `Enum` (declared members only — a `[Flags]` enum widens to + every combination with `AllowingCombinations()`), `TimeSpan`, `DateTime` (UTC) and `DateTimeOffset`. On modern targets (`net8.0`) the surface extends to `DateOnly`, `TimeOnly`, `Int128`, `UInt128` and `Half`; the package also targets `netstandard2.0` and runs on **.NET Framework 4.7.2+**, .NET Core 2.0+ and .NET 5+ diff --git a/doc/handwritten/for-maintainers/adr/0041-draw-flag-enum-combinations-behind-an-opt-in.fr.md b/doc/handwritten/for-maintainers/adr/0041-draw-flag-enum-combinations-behind-an-opt-in.fr.md new file mode 100644 index 00000000..47cc15a5 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0041-draw-flag-enum-combinations-behind-an-opt-in.fr.md @@ -0,0 +1,97 @@ +# ADR-0041 | Tirer les combinaisons d'enums de drapeaux derrière un opt-in + +🌍 🇬🇧 [English](0041-draw-flag-enum-combinations-behind-an-opt-in.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-26 +**Décideurs :** Reefact + +## Contexte + +Un enum marqué `[Flags]` déclare des bits destinés à être combinés : ses membres ne sont pas des alternatives mais les parties d'un ensemble. Ses valeurs **valides** sont donc les combinaisons, alors que les valeurs qu'il **déclare** n'en sont que les parties — `Read | Write` est une valeur que le type est conçu pour porter et qu'il ne nomme jamais. La BCL le confirme deux fois : `Enum.GetValues` ne renvoie que les membres déclarés, et `Enum.IsDefined` répond `false` pour une combinaison. + +`AnyEnum` tire uniformément parmi les membres déclarés, un contrat que ses propres remarks énoncent. Pour un enum de drapeaux, cela signifie qu'un dummy porte au plus un bit : une branche qui en lit deux — la forme ordinaire du code consommant des drapeaux — n'est donc jamais exercée par une valeur JustDummies. C'est l'inverse de la raison d'être de la bibliothèque : la surface de contraintes existe pour faire remonter les hypothèses cachées, et ici le générateur en installe silencieusement une à son tour (« cette valeur a zéro ou un bit »). C'est la forme même d'un défaut d'atteignabilité, atteint par conception. + +Le générateur est tenu par trois règles permanentes de la bibliothèque. Il construit ses valeurs de manière constructive, en un tirage, sans jamais générer-puis-filtrer. Il détecte les contraintes contradictoires au moment de l'appel fluide qui les cause, en nommant les deux côtés. Et il annonce une cardinalité distincte via `ICardinalityHint`, ce qui permet à une collection distincte d'enums d'échouer à la déclaration plutôt qu'à la génération — la taille du domaine de tirage fait donc partie du contrat public, pas du détail d'implémentation. + +Deux propriétés des vrais enums de drapeaux comptent pour la forme du domaine. Un enum de drapeaux n'est pas tenu de déclarer un membre nul, et celui qui n'en déclare pas n'a aucune valeur « aucun drapeau » à rendre. Et un enum de drapeaux peut déclarer des **composites** — `ReadWrite = Read | Write`, `All = 7` — qui sont déjà des combinaisons, si bien que plusieurs sous-ensembles des membres déclarés retombent sur la même valeur. + +JustDummies n'a jamais été publié : le sens du tirage non contraint est donc encore libre d'être figé. L'audit du 2026-07-20 a recensé les combinaisons de drapeaux comme un ajout piloté par la demande (issue #226). + +## Décision + +`AnyEnum` continue de tirer parmi les membres déclarés par défaut et acquiert `AllowingCombinations()`, une contrainte explicite élargissant le tirage à la clôture par OU des membres déclarés — plus la valeur nulle lorsque l'enum en déclare une —, refusée sur un enum qui n'est pas `[Flags]` et sur un enum comptant plus de membres non nuls qu'il n'est possible d'énumérer. + +## Justification + +**Le défaut ne peut pas dépendre de l'attribut.** Faire que `Any.Enum()` se comporte différemment parce que le type porte `[Flags]` rendrait le tirage fonction des métadonnées d'un type plutôt que de ce que le test a écrit, c'est-à-dire exactement la classe de comportement implicite et à distance qu'ADR-0020 a retirée de cette bibliothèque en supprimant les conversions implicites. « Membres déclarés uniquement » est aussi le seul défaut *valide* pour les deux familles d'enums : un membre déclaré est toujours une valeur légitime, alors qu'une combinaison ne l'est que pour un enum de drapeaux. Le conserver coûte un appel à l'utilisateur de drapeaux et ne coûte rien à tous les autres. + +**En faire une contrainte, et non une seconde factory,** place le choix là où le lecteur cherche déjà la forme d'une valeur. `Any.Enum().AllowingCombinations()` se lit comme un élargissement du même générateur, se compose avec `OneOf`/`Except`/`DifferentFrom` à travers le pool existant, et ne demande aucun miroir sur `AnyContext` — la factory est inchangée, donc la surface miroir maintenue à la main ne grandit pas. + +**L'univers est la clôture par OU des membres déclarés, non celle des bits individuels.** Prendre les membres déclarés comme ensemble générateur absorbe un composite déclaré sans avoir à décider quels membres « sont » des bits : `ReadWrite = Read | Write` n'ajoute rien, et un enum dont les membres ne sont pas tous des puissances de deux ne demande aucun cas particulier. N'ajouter la valeur nulle que lorsqu'un membre nul est déclaré préserve la promesse que toute valeur tirée est une valeur définie par le type : un enum ne déclarant que `Left` et `Right` n'a pas de nom pour l'ensemble vide, et l'inventer serait précisément la valeur non déclarée que le défaut refuse. + +**Les exclusions continuent de comparer par égalité.** `Except(Read)` interdit la valeur `Read` et laisse `Read | Write` tirable. Lire le même appel comme un masque de bits sous l'opt-in ferait qu'une méthode signifie deux choses selon qu'une autre contrainte a été déclarée — la même implicité que le défaut rejette — et supprimerait silencieusement la majeure partie de l'univers. La bibliothèque distingue déjà les quasi-synonymes par le nom quand l'intention diffère : une exclusion au niveau du bit, si elle est un jour souhaitée, sera une contrainte nommée à part plutôt qu'une mutation de celle-ci. + +**Énumérer l'univers est ce qui préserve les deux garanties permanentes.** Un tirage indépendant par membre serait moins coûteux et sans plafond, mais il est uniforme sur les *sous-ensembles*, pas sur les *valeurs* : en présence d'un composite déclaré, plusieurs sous-ensembles retombent sur une même valeur, qui sort alors bien plus souvent que les autres — et un dummy biaisé est un échec plus grave qu'une contrainte refusée, parce que rien ne le révèle. Matérialiser la clôture garde aussi `ICardinalityHint` exact, ce qui préserve le conflit anticipé sur une collection distincte demandant plus de valeurs qu'il n'en existe. Le prix est que la clôture est exponentielle en nombre de membres : il lui faut un plafond. + +**Au-delà du plafond, la contrainte est refusée, pas dégradée.** Un repli silencieux scinderait le générateur en deux régimes — l'un uniforme et vérifié à la déclaration, l'autre ni l'un ni l'autre — que seul le comptage des membres d'un enum permettrait de distinguer. Refuser en nommant la cause, et pointer vers la liste explicite qui sert le cas, est la réponse qu'ADR-0025 a déjà donnée pour les constructions hors du sous-ensemble supporté : une erreur claire vaut mieux qu'une valeur dont l'appelant ne peut prévoir les propriétés. Un enum de drapeaux assez large pour atteindre le plafond est très loin des formes que le vrai code déclare. + +## Alternatives envisagées + +### Faire des combinaisons le défaut pour les enums `[Flags]` + +Envisagée parce qu'elle ne demande aucune API nouvelle et donne à l'utilisateur de drapeaux le bon domaine sans qu'il ait à le demander : « arbitraire mais valide » signifie sans doute déjà les combinaisons pour un type conçu pour les porter. + +Rejetée parce que le tirage dépendrait alors des métadonnées du type et non du texte du test : ajouter `[Flags]` à un enum existant changerait silencieusement tout dummy tiré de lui — et, avant cela, toute séquence seedée. Elle élargit aussi le domaine pour les nombreux enums de drapeaux dont les consommateurs ne passent jamais que des membres simples, où un dummy à deux bits est une surprise plutôt qu'une révélation. L'appel explicite coûte une ligne et rend l'élargissement lisible au point d'appel. + +### Tirer chaque membre par un pile-ou-face indépendant + +Envisagée parce qu'elle tient en quelques lignes, n'a pas de plafond et ne demande aucune énumération : on OR un sous-ensemble aléatoire des membres et le résultat est une combinaison valide par construction. + +Rejetée parce qu'elle est uniforme sur les sous-ensembles et non sur les valeurs, si bien que tout composite déclaré biaise fortement la distribution vers la valeur télescopée, et parce qu'elle ne peut annoncer aucune cardinalité distincte — ce qui ferait silencieusement passer les collections distinctes d'enums d'un conflit anticipé à un tirage borné, une régression par rapport au comportement actuel. + +### Exposer les combinaisons par une factory séparée + +Envisagée parce qu'un point d'entrée distinct énoncerait l'intention encore plus fort et pourrait porter sa propre surface de contraintes. + +Rejetée parce qu'elle duplique toute l'algèbre de contraintes des enums pour un seul élargissement, et parce qu'il faudrait la mirrorer sur `AnyContext`, faisant grandir la surface miroir que les gardes de parité existent pour surveiller. Une contrainte sur le builder existant se compose avec tout ce qui y est déjà. + +### Lire `Except` comme un masque de bits sous l'opt-in + +Envisagée parce que « aucune valeur portant le bit Read » est une demande plausible, et que réutiliser `Except` n'exigerait aucun nom nouveau. + +Rejetée parce qu'elle fait qu'une méthode signifie deux choses différentes selon qu'une autre contrainte a été déclarée, et parce qu'elle est la plus destructrice des deux lectures : exclure un bit retirerait la moitié de l'univers, ce qu'un appelant écrivant `Except` sur un enum n'a aucune raison d'attendre. Un nom distinct reste disponible pour ce besoin. + +## Conséquences + +### Positives + +* Un dummy de drapeaux peut porter les combinaisons que le type existe pour porter : une branche lisant deux bits est exercée. +* Le défaut est inchangé : aucun tirage existant, aucune séquence seedée, aucun comportement documenté ne bouge. +* Le domaine élargi passe par le hint de cardinalité existant, donc une collection distincte de combinaisons continue d'échouer à la déclaration plutôt qu'à la génération. +* Les refus — pas `[Flags]`, trop de membres — ont lieu à la déclaration et nomment leur cause, comme le reste de la surface de contraintes. + +### Négatives + +* L'utilisateur de drapeaux doit savoir que l'appel existe ; un générateur tirant des membres simples reste le défaut qu'il rencontre d'abord. +* L'univers est matérialisé : un enum proche du plafond coûte de la mémoire et un calcul unique proportionnels à son nombre de combinaisons. +* L'opt-in est sensible à l'ordre face à une liste d'autorisation nommant des combinaisons : appliqué après `OneOf`, il élargit un univers que la liste a déjà épinglé, et ne change donc rien. + +### Risques + +* Un enum assez large pour être refusé est un type supporté dont les combinaisons ne peuvent pas être tirées du tout. Mitigation : le message nomme le plafond et pointe vers la liste explicite ; la forme est très loin de ce que le vrai code déclare, et le plafond peut être relevé par une décision ultérieure sur preuves. +* La sensibilité à l'ordre avec `OneOf` pourrait se lire comme un no-op silencieux. Mitigation : documentée sur les deux membres, et l'ordre inverse — une liste nommant une combinaison avant l'opt-in — échoue avec un message nommant la contrainte manquante plutôt que de l'accepter. + +## Actions de suivi + +* Si une exclusion au niveau du bit est demandée, l'introduire sous son propre nom plutôt qu'en élargissant `Except`. +* Revisiter le plafond d'énumération si un vrai enum de drapeaux est un jour signalé contre lui. + +## Références + +* [ADR-0020](0020-materialize-dummies-only-through-generate.fr.md) — la suppression du comportement implicite piloté par les métadonnées dont le défaut reprend le raisonnement, et l'argument de calendrier pré-1.0 réutilisé ici. +* [ADR-0025](0025-generate-strings-from-a-home-grown-regular-subset.fr.md) — la règle « un refus clair vaut mieux qu'une valeur imprévisible » que le plafond applique. +* [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md) — le contrat du hint de cardinalité que l'univers matérialisé garde exact. +* [ADR-0032](0032-draw-arbitrary-values-from-an-explicit-top-level-pool.fr.md) — le tirage sur pool explicite vers lequel pointe le message du plafond. +* [ADR-0037](0037-vary-the-datetimeoffset-offset-dimension.fr.md) — le précédent d'une dimension optionnelle dont le défaut reste intouché, y compris la même interaction d'énumération terminale avec `OneOf`. +* Issue [#226](https://github.com/Reefact/first-class-errors/issues/226) — l'entrée de backlog que ceci résout. diff --git a/doc/handwritten/for-maintainers/adr/0041-draw-flag-enum-combinations-behind-an-opt-in.md b/doc/handwritten/for-maintainers/adr/0041-draw-flag-enum-combinations-behind-an-opt-in.md new file mode 100644 index 00000000..4c96acfd --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0041-draw-flag-enum-combinations-behind-an-opt-in.md @@ -0,0 +1,97 @@ +# ADR-0041 | Draw flag-enum combinations behind an opt-in + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0041-draw-flag-enum-combinations-behind-an-opt-in.fr.md) + +**Status:** Accepted +**Date:** 2026-07-26 +**Decision Makers:** Reefact + +## Context + +An enum marked `[Flags]` declares bits meant to be combined: its members are not alternatives but the parts of a set. Its **valid** values are therefore the combinations, while the values it **declares** are only the parts — `Read | Write` is a value the type is designed to hold and never names. The BCL agrees on both counts: `Enum.GetValues` returns the declared members only, and `Enum.IsDefined` answers `false` for a combination. + +`AnyEnum` draws uniformly from the declared members, a contract its own remarks state. For a flags enum this means a dummy carries at most one bit, so a branch reading two — the ordinary shape of flag-consuming code — is never exercised by a JustDummies value. That is the inverse of what the library exists for: the constraint surface is meant to surface hidden assumptions, and here the generator silently installs one of its own ("this value has zero or one bit"). It is the same shape as a reachability defect, except reached by design. + +The generator is bound by three of the library's standing rules. It builds values constructively in one draw and never generates-then-filters. It detects contradictory constraints eagerly, at the fluent call that caused them, naming both sides. And it advertises a distinct cardinality through `ICardinalityHint`, which is what lets a distinct collection over an enum fail at declaration rather than at generation — so the size of the draw domain is part of the public contract, not an implementation detail. + +Two properties of real flags enums matter for the domain's shape. A flags enum need not declare a zero member, and one that does not has no "no flags" value to yield. And a flags enum may declare **composites** — `ReadWrite = Read | Write`, `All = 7` — which are combinations already, so several subsets of the declared members collapse onto the same value. + +JustDummies has never been released, so the meaning of the unconstrained draw is still free to be fixed. The audit of 2026-07-20 recorded flag combinations as a demand-driven addition (issue #226). + +## Decision + +`AnyEnum` keeps drawing from the declared members by default and gains `AllowingCombinations()`, an explicit constraint widening the draw to the OR-closure of the declared members — plus the zero value when the enum declares one — refused on an enum that is not `[Flags]` and on one with more non-zero members than can be enumerated. + +## Rationale + +**The default cannot depend on the attribute.** Making `Any.Enum()` behave differently because the type carries `[Flags]` would make the draw a function of a type's metadata rather than of what the test wrote, which is the class of implicit, action-at-a-distance behaviour ADR-0020 removed from this library when it deleted the implicit conversions. Declared-members-only is also the sole default that is *valid* for both enum families: a declared member is always a legitimate value, whereas a combination is legitimate only for a flags enum. Keeping it costs the flags user one call and costs everyone else nothing. + +**Making it a constraint, not a second factory,** puts the choice where the reader already looks for the shape of a value. `Any.Enum().AllowingCombinations()` reads as a widening of the same generator, composes with `OneOf`/`Except`/`DifferentFrom` through the existing pool, and needs no mirror on `AnyContext` — the factory is unchanged, so the hand-mirrored surface does not grow. + +**The universe is the OR-closure of the declared members, not of the individual bits.** Taking the declared members as the generating set absorbs a declared composite without having to decide which members "are" bits: `ReadWrite = Read | Write` contributes nothing new, and an enum whose members are not all powers of two needs no special case. Adding the zero value only when a zero member is declared keeps the promise that every drawn value is one the type defines: an enum declaring `Left` and `Right` alone has no name for the empty set, and inventing it would be exactly the undeclared value the declared-members default refuses. + +**Exclusions keep comparing by equality.** `Except(Read)` forbids the value `Read` and leaves `Read | Write` drawable. Reading the same call as a bit mask under the opt-in would make one method mean two things depending on another constraint — the same implicitness the default rejects — and would silently delete most of the universe. The library already distinguishes near-synonyms by name when the intent differs, so a bit-level exclusion, if it is ever wanted, is a separate named constraint rather than a mutation of this one. + +**Enumerating the universe is what keeps the two standing guarantees.** A per-member coin flip would be cheaper and unbounded, but it is uniform over *subsets*, not over *values*: with a declared composite, several subsets collapse onto one value, and that value is then drawn far more often than the others — a biased dummy is a worse failure than a refused constraint, because nothing reveals it. Materializing the closure also keeps `ICardinalityHint` exact, which is what preserves the eager conflict on a distinct collection asking for more values than exist. The cost is that the closure is exponential in the number of members, so it needs a ceiling. + +**Beyond the ceiling the constraint is refused, not degraded.** A silent fallback would split the generator into two regimes — one uniform and eagerly-checked, one neither — distinguishable only by counting an enum's members. Refusing by name, and pointing at the explicit allow-list that serves the case, is the answer ADR-0025 already gave for constructs outside the supported subset: a clear error beats a value whose properties the caller cannot predict. A flags enum wide enough to hit the ceiling is far outside the shapes real code declares. + +## Alternatives Considered + +### Make combinations the default for `[Flags]` enums + +Considered because it needs no new API and gives the flags user the right domain without asking: arguably "arbitrary yet valid" already means the combinations for a type designed to hold them. + +Rejected because the draw would then depend on the type's metadata rather than on the test's text, so adding `[Flags]` to an existing enum would silently change every dummy drawn from it — and, before that, change every seeded sequence. It also widens the domain for the many flags enums whose consumers only ever pass single members, where a two-bit dummy is a surprise rather than a revelation. The explicit call costs one line and makes the widening legible at the call site. + +### Draw each member with an independent coin flip + +Considered because it is a few lines, has no ceiling, and needs no enumeration at all: OR a random subset of the members and the result is a valid combination by construction. + +Rejected because it is uniform over subsets rather than over values, so any declared composite skews the distribution heavily towards the collapsed value, and because it cannot report a distinct cardinality — which would silently drop distinct collections over an enum from an eager conflict to a bounded draw, a regression against today's behaviour. + +### Expose the combinations through a separate factory + +Considered because a distinct entry point would state the intent even more loudly and could carry its own constraint surface. + +Rejected because it duplicates the whole enum constraint algebra for one widening, and because it would have to be mirrored on `AnyContext`, growing the hand-mirrored surface the parity guards exist to police. A constraint on the existing builder composes with everything already there. + +### Read `Except` as a bit mask under the opt-in + +Considered because "no value carrying the Read bit" is a plausible thing a test wants, and reusing `Except` would need no new name. + +Rejected because it makes one method mean two different things depending on whether another constraint was declared, and because it is the more destructive of the two readings: excluding one bit would remove half the universe, which a caller writing `Except` on an enum has no reason to expect. A distinct name remains available for that need. + +## Consequences + +### Positive + +* A flags dummy can carry the combinations the type exists to hold, so a branch reading two bits is exercised. +* The default is unchanged, so no existing draw, seeded sequence, or documented behaviour moves. +* The widened domain flows through the existing cardinality hint, so a distinct collection over combinations keeps failing eagerly rather than at generation. +* The refusals — not `[Flags]`, too many members — are declaration-time and name their cause, consistent with the rest of the constraint surface. + +### Negative + +* The flags user must know the call exists; a generator drawing single members remains the default they meet first. +* The universe is materialized, so an enum near the ceiling costs memory and one-off computation proportional to its combination count. +* The opt-in is order-sensitive with respect to an allow-list naming combinations: applied after `OneOf`, it widens a universe the allow-list has already pinned, so it changes nothing. + +### Risks + +* An enum wide enough to be refused is a supported type whose combinations cannot be drawn at all. Mitigation: the message names the ceiling and points at the explicit allow-list; the shape is far outside what real code declares, and the ceiling can be raised by a later decision on evidence. +* The order-sensitivity with `OneOf` could read as a silent no-op. Mitigation: documented on both members, and the reverse order — an allow-list naming a combination before the opt-in — fails with a message naming the missing constraint rather than accepting it. + +## Follow-up Actions + +* Should a bit-level exclusion be requested, introduce it under its own name rather than by widening `Except`. +* Revisit the enumeration ceiling if a real flags enum is ever reported against it. + +## References + +* [ADR-0020](0020-materialize-dummies-only-through-generate.md) — the removal of implicit, metadata-driven behaviour whose reasoning the default follows, and the pre-1.0 timing argument reused here. +* [ADR-0025](0025-generate-strings-from-a-home-grown-regular-subset.md) — the "a clear refusal beats an unpredictable value" rule the ceiling applies. +* [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) — the cardinality-hint contract the materialized universe keeps exact. +* [ADR-0032](0032-draw-arbitrary-values-from-an-explicit-top-level-pool.md) — the explicit-pool draw the ceiling's message points at. +* [ADR-0037](0037-vary-the-datetimeoffset-offset-dimension.md) — the precedent for an opt-in extra dimension whose default is left untouched, including the same `OneOf` terminal-enumeration interaction. +* Issue [#226](https://github.com/Reefact/first-class-errors/issues/226) — the backlog entry this resolves. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 623dbc8a..ee0fd471 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -223,3 +223,4 @@ Optional supporting material: | [ADR-0038](0038-open-the-ambient-seed-scope-to-adapters.md) | Open the ambient seed scope to test-framework adapters | Accepted | | [ADR-0039](0039-adapt-dummies-to-xunit-v3-through-a-companion-package.md) | Adapt JustDummies to xUnit v3 through a companion package | Accepted | | [ADR-0040](0040-split-the-justdummies-test-bed-between-example-and-property-suites.md) | Split the JustDummies test bed between an example suite and a property suite | Accepted | +| [ADR-0041](0041-draw-flag-enum-combinations-behind-an-opt-in.md) | Draw flag-enum combinations behind an opt-in | Accepted |