From faf7ffa577abc0cf2a3000d7f63102e334b66f47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 07:55:42 +0000 Subject: [PATCH 1/2] feat(dummies): add collection and tuple generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 3 extends the DSL from scalars to collections, composed over any element generator rather than filled by reflection. - New generators: Any.ListOf / ArrayOf / SequenceOf (materialized) / SetOf / DictionaryOf, plus Any.PairOf / TripleOf sugar over Combine. Each is a combinator that derives its random context from the element generator (falling back to the ambient source), so a seeded element keeps the whole collection reproducible. - Shared count engine CountSpec mirrors the string length algebra: WithCount / WithMinCount / WithMaxCount / WithCountBetween / NonEmpty / Empty, with eager conflict detection. Unconstrained collections hold 0..8 elements — smaller than the string spread, since elements are themselves generated values. - Distinctness follows a two-layer contract. Small element domains advertise an upper-bound cardinality (ICardinalityHint, wired through the ordinal spec and the bool/enum/char/guid pools), so a count beyond the domain fails eagerly with ConflictingAnyConstraintException; larger or unknown domains use a bounded dedup-draw and surface a genuine shortfall as AnyGenerationException naming the seed. The bound stays sound under a custom comparer, which can only merge values. - Containing(value) forces required elements into a collection (budgeted like string fragments); ContainingAny(generator) forces a drawn value in — named apart because a generator both is an IAny and converts implicitly to its value. Values are built to satisfy the constraints directly, never generate-then-filter. The library stays free of FirstClassErrors and targets netstandard2.0 and net8.0. Full suite green on both legs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhPrBqngpiSwdsF4DWvNPw --- Dummies.UnitTests/AnyCollectionTests.cs | 238 ++++++++++++++++++ Dummies.UnitTests/SeedReproducibilityTests.cs | 5 +- Dummies/Any.cs | 150 +++++++++++ Dummies/AnyArray.cs | 53 ++++ Dummies/AnyBool.cs | 5 +- Dummies/AnyByte.cs | 4 +- Dummies/AnyChar.cs | 5 +- Dummies/AnyCollection.cs | 158 ++++++++++++ Dummies/AnyDateOnly.cs | 4 +- Dummies/AnyDateTime.cs | 4 +- Dummies/AnyDateTimeOffset.cs | 4 +- Dummies/AnyDerivation.cs | 8 + Dummies/AnyDictionary.cs | 138 ++++++++++ Dummies/AnyEnum.cs | 5 +- Dummies/AnyGenerationException.cs | 4 + Dummies/AnyGuid.cs | 5 +- Dummies/AnyInt16.cs | 4 +- Dummies/AnyInt32.cs | 4 +- Dummies/AnyInt64.cs | 4 +- Dummies/AnyList.cs | 53 ++++ Dummies/AnySByte.cs | 4 +- Dummies/AnySequence.cs | 47 ++++ Dummies/AnySet.cs | 40 +++ Dummies/AnyTimeOnly.cs | 4 +- Dummies/AnyTimeSpan.cs | 4 +- Dummies/AnyUInt16.cs | 4 +- Dummies/AnyUInt32.cs | 4 +- Dummies/AnyUInt64.cs | 4 +- Dummies/CollectionState.cs | 223 ++++++++++++++++ Dummies/CountSpec.cs | 145 +++++++++++ Dummies/ICardinalityHint.cs | 23 ++ Dummies/OrdinalIntervalSpec.cs | 16 ++ Dummies/README.nuget.md | 5 + 33 files changed, 1360 insertions(+), 18 deletions(-) create mode 100644 Dummies.UnitTests/AnyCollectionTests.cs create mode 100644 Dummies/AnyArray.cs create mode 100644 Dummies/AnyCollection.cs create mode 100644 Dummies/AnyDictionary.cs create mode 100644 Dummies/AnyList.cs create mode 100644 Dummies/AnySequence.cs create mode 100644 Dummies/AnySet.cs create mode 100644 Dummies/CollectionState.cs create mode 100644 Dummies/CountSpec.cs create mode 100644 Dummies/ICardinalityHint.cs diff --git a/Dummies.UnitTests/AnyCollectionTests.cs b/Dummies.UnitTests/AnyCollectionTests.cs new file mode 100644 index 00000000..7c40fdd3 --- /dev/null +++ b/Dummies.UnitTests/AnyCollectionTests.cs @@ -0,0 +1,238 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +public sealed class AnyCollectionTests { + + #region Statics members declarations + + private const int SampleCount = 200; + + private enum Suit { + + Clubs, + Diamonds, + Hearts, + Spades + + } + + #endregion + + [Fact(DisplayName = "ListOf: unconstrained draws vary in size, stay within 0..8, and hold elements from the item generator.")] + public void ListOfUnconstrained() { + HashSet sizes = new(); + for (int i = 0; i < SampleCount; i++) { + List list = Any.ListOf(Any.Int32().Between(1, 9)).Generate(); + sizes.Add(list.Count); + Check.That(list.Count).IsGreaterOrEqualThan(0); + Check.That(list.Count).IsLessOrEqualThan(8); + Check.That(list).ContainsOnlyElementsThatMatch(value => value is >= 1 and <= 9); + } + Check.That(sizes.Count).IsStrictlyGreaterThan(1); + } + + [Fact(DisplayName = "ListOf: the count family fixes, floors, caps and ranges the size.")] + public void ListOfCountFamily() { + for (int i = 0; i < SampleCount; i++) { + Check.That(((List)Any.ListOf(Any.Int32()).WithCount(5)).Count).IsEqualTo(5); + Check.That(((List)Any.ListOf(Any.Int32()).Empty()).Count).IsEqualTo(0); + Check.That(((List)Any.ListOf(Any.Int32()).NonEmpty()).Count).IsStrictlyGreaterThan(0); + Check.That(((List)Any.ListOf(Any.Int32()).WithMinCount(3)).Count).IsGreaterOrEqualThan(3); + Check.That(((List)Any.ListOf(Any.Int32()).WithMaxCount(2)).Count).IsLessOrEqualThan(2); + + int ranged = ((List)Any.ListOf(Any.Int32()).WithCountBetween(4, 6)).Count; + Check.That(ranged is >= 4 and <= 6).IsTrue(); + } + } + + [Fact(DisplayName = "ListOf: contradictory count constraints fail eagerly naming both sides.")] + public void ListOfCountConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.ListOf(Any.Int32()).WithCount(3).WithMinCount(5)); + Check.That(conflict.Message).Contains("WithCount(3)"); + + Check.ThatCode(() => Any.ListOf(Any.Int32()).WithMinCount(5).WithMaxCount(3)).Throws(); + Check.ThatCode(() => Any.ListOf(Any.Int32()).WithCount(2).WithCount(3)).Throws(); + } + + [Fact(DisplayName = "ListOf: count constraints validate their arguments.")] + public void ListOfCountValidation() { + Check.ThatCode(() => Any.ListOf(Any.Int32()).WithCount(-1)).Throws(); + Check.ThatCode(() => Any.ListOf(Any.Int32()).WithMinCount(-1)).Throws(); + Check.ThatCode(() => Any.ListOf(Any.Int32()).WithCountBetween(6, 4)).Throws(); + Check.ThatCode(() => Any.ListOf(null!)).Throws(); + } + + [Fact(DisplayName = "Distinct: a wide-domain distinct list holds only distinct elements.")] + public void DistinctOverAWideDomain() { + for (int i = 0; i < SampleCount; i++) { + List list = Any.ListOf(Any.Int32().Between(1, 1000)).WithCount(20).Distinct(); + Check.That(list.Count).IsEqualTo(20); + Check.That(new HashSet(list).Count).IsEqualTo(20); + } + } + + [Fact(DisplayName = "Distinct: a count beyond the element cardinality conflicts eagerly, naming the shortfall.")] + public void DistinctCardinalityConflictsEagerly() { + ConflictingAnyConstraintException fromBool = Assert.Throws( + () => Any.SetOf(Any.Bool()).WithCount(3)); + Check.That(fromBool.Message).Contains("2 distinct value"); + + Check.ThatCode(() => Any.SetOf(Any.Enum()).WithMinCount(5)).Throws(); + Check.ThatCode(() => Any.SetOf(Any.Int32().Between(1, 3)).WithCount(5)).Throws(); + Check.ThatCode(() => Any.ListOf(Any.Int32().Between(1, 3)).WithCount(5).Distinct()).Throws(); + // Order-independent: turning distinct on after the count is set conflicts just the same. + Check.ThatCode(() => Any.ListOf(Any.Bool()).WithCount(3).Distinct()).Throws(); + } + + [Fact(DisplayName = "Distinct: an unknowable small domain cannot be detected early, so a shortfall surfaces at generation.")] + public void DistinctFallbackThrowsAtGeneration() { + // '.As' erases the cardinality hint, so the conflict cannot be seen at declaration — the bounded dedup-draw + // fallback catches it while generating instead. + IAny opaque = Any.Int32().Between(1, 3).As(value => value); + + Check.ThatCode(() => Any.SetOf(opaque).WithCount(5).Generate()).Throws(); + } + + [Fact(DisplayName = "SetOf: elements are always distinct and drawn from the item generator.")] + public void SetOfIsDistinct() { + for (int i = 0; i < SampleCount; i++) { + HashSet set = Any.SetOf(Any.Int32().Between(1, 500)).WithCount(10); + Check.That(set.Count).IsEqualTo(10); + Check.That(set).ContainsOnlyElementsThatMatch(value => value is >= 1 and <= 500); + } + } + + [Fact(DisplayName = "SetOf: a comparer merges values, so cardinality is only an upper bound and the fallback still guards.")] + public void SetOfHonoursAComparer() { + IEqualityComparer modTen = new ModuloComparer(10); + + for (int i = 0; i < SampleCount; i++) { + HashSet set = Any.SetOf(Any.Int32().Between(0, 999), modTen).WithCount(5); + Check.That(set.Count).IsEqualTo(5); + List classes = set.Select(value => value % 10).ToList(); + Check.That(classes.Count).IsEqualTo(new HashSet(classes).Count); + } + + // Only ten residue classes exist, so twenty distinct-under-the-comparer elements are impossible; the raw + // cardinality (1000) hides that, so it can only be caught while drawing. + Check.ThatCode(() => Any.SetOf(Any.Int32().Between(0, 999), modTen).WithCount(20).Generate()).Throws(); + } + + [Fact(DisplayName = "Containing: a required value is present, and a distinct duplicate requirement conflicts.")] + public void ContainingPlacesValues() { + for (int i = 0; i < SampleCount; i++) { + List list = Any.ListOf(Any.Int32().Between(1, 9)).WithCount(5).Containing(777); + Check.That(list).Contains(777); + Check.That(list.Count).IsEqualTo(5); + } + + Check.ThatCode(() => Any.ListOf(Any.Int32()).WithCount(1).Containing(1).Containing(2)).Throws(); + + ConflictingAnyConstraintException duplicate = Assert.Throws( + () => Any.SetOf(Any.Int32()).Containing(7).Containing(7)); + Check.That(duplicate.Message).Contains("more than once"); + } + + [Fact(DisplayName = "Containing: a value drawn from a generator is forced into the collection.")] + public void ContainingFromAGenerator() { + for (int i = 0; i < SampleCount; i++) { + List list = Any.ListOf(Any.Int32().Between(1, 9)).NonEmpty().ContainingAny(Any.Int32().OneOf(4242)); + Check.That(list).Contains(4242); + } + } + + [Fact(DisplayName = "ArrayOf: produces an array of the requested size, distinct when asked.")] + public void ArrayOfProducesArrays() { + for (int i = 0; i < SampleCount; i++) { + int[] array = Any.ArrayOf(Any.Int32().Between(1, 100)).WithCount(6).Distinct(); + Check.That(array.Length).IsEqualTo(6); + Check.That(new HashSet(array).Count).IsEqualTo(6); + } + } + + [Fact(DisplayName = "SequenceOf: is fully materialized — enumerating twice yields the same elements without re-drawing.")] + public void SequenceOfIsMaterialized() { + IEnumerable sequence = Any.SequenceOf(Any.Int32()).WithCount(5).Generate(); + + List first = sequence.ToList(); + List second = sequence.ToList(); + + Check.That(first).ContainsExactly(second); + } + + [Fact(DisplayName = "DictionaryOf: builds unique-keyed dictionaries and gates the count by the key domain.")] + public void DictionaryOfBehaves() { + for (int i = 0; i < SampleCount; i++) { + Dictionary dictionary = Any.DictionaryOf(Any.Int32().Between(1, 1000), Any.String().NonEmpty()).WithCount(8); + Check.That(dictionary.Count).IsEqualTo(8); + Check.That(dictionary.Values).ContainsOnlyElementsThatMatch(value => value.Length > 0); + } + + Check.ThatCode(() => Any.DictionaryOf(Any.Bool(), Any.Int32()).WithCount(3)).Throws(); + Check.ThatCode(() => Any.DictionaryOf(null!, Any.Int32())).Throws(); + } + + [Fact(DisplayName = "PairOf and TripleOf assemble value tuples from constrained parts.")] + public void PairAndTriple() { + for (int i = 0; i < SampleCount; i++) { + (int first, string second) pair = Any.PairOf(Any.Int32().Positive(), Any.String().NonEmpty()).Generate(); + Check.That(pair.first).IsStrictlyGreaterThan(0); + Check.That(pair.second).IsNotEmpty(); + + (int a, int b, int c) triple = Any.TripleOf(Any.Int32().Between(1, 2), Any.Int32().Between(3, 4), Any.Int32().Between(5, 6)).Generate(); + Check.That(triple.a is 1 or 2).IsTrue(); + Check.That(triple.b is 3 or 4).IsTrue(); + Check.That(triple.c is 5 or 6).IsTrue(); + } + } + + [Fact(DisplayName = "Collections are reproducible when their element generator draws from a seeded context.")] + public void CollectionsAreReproducible() { + HashSet first = Any.SetOf(Any.WithSeed(4242).Int32()).WithCount(6); + HashSet second = Any.SetOf(Any.WithSeed(4242).Int32()).WithCount(6); + + Check.That(second.OrderBy(value => value)).ContainsExactly(first.OrderBy(value => value)); + + List listOne = Any.ListOf(Any.WithSeed(7).Int32().Between(0, 99)).WithCount(5); + List listTwo = Any.ListOf(Any.WithSeed(7).Int32().Between(0, 99)).WithCount(5); + Check.That(listTwo).ContainsExactly(listOne); + } + + [Fact(DisplayName = "Collections compose into value objects and aggregates through As and Combine.")] + public void CollectionsComposeThroughAsAndCombine() { + IAny> references = Any.ListOf(Any.String().StartingWith("ORD-").WithLength(12).As(OrderReference.Create)).WithCount(3); + + List list = references.Generate(); + Check.That(list.Count).IsEqualTo(3); + Check.That(list).ContainsOnlyElementsThatMatch(reference => reference.Value.StartsWith("ORD-")); + } + + #region Nested types + + private sealed class ModuloComparer : IEqualityComparer { + + private readonly int _modulus; + + public ModuloComparer(int modulus) { + _modulus = modulus; + } + + public bool Equals(int x, int y) { + return x % _modulus == y % _modulus; + } + + public int GetHashCode(int obj) { + return obj % _modulus; + } + + } + + #endregion + +} diff --git a/Dummies.UnitTests/SeedReproducibilityTests.cs b/Dummies.UnitTests/SeedReproducibilityTests.cs index 138a2687..0cc81ac9 100644 --- a/Dummies.UnitTests/SeedReproducibilityTests.cs +++ b/Dummies.UnitTests/SeedReproducibilityTests.cs @@ -32,10 +32,13 @@ private static string Batch() { DateTime instant = Any.DateTime(); Int128 huge = Any.Int128(); 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); return string.Join("|", full, bounded, free, capped, shaped, wide, unsigned, real, exact, flag, id, letter, - span.Ticks, instant.Ticks, huge, tiny); + span.Ticks, instant.Ticks, huge, tiny, + string.Join("-", list), string.Join("-", set.OrderBy(value => value))); } #endregion diff --git a/Dummies/Any.cs b/Dummies/Any.cs index cd2fef83..86c1fb0f 100644 --- a/Dummies/Any.cs +++ b/Dummies/Any.cs @@ -430,6 +430,156 @@ public static IAny Combine(IAny first, IAny + /// Starts an arbitrary generator over . Unconstrained, it yields + /// 0 to 8 elements; chain constraints to express what the surrounding code requires (NonEmpty(), + /// WithCount(...), Distinct(), Containing(...)). + /// + /// The generator each element is drawn from. + /// The element type. + /// A list generator to constrain fluently. + /// Thrown when is null. + public static AnyList ListOf(IAny item) { + if (item is null) { throw new ArgumentNullException(nameof(item)); } + + return new AnyList(AnyDerivation.SourceOf(item), CollectionState.Create(item, false, null)); + } + + /// + /// Starts an arbitrary array (T[]) generator over — same constraint surface as + /// , producing an array. + /// + /// The generator each element is drawn from. + /// The element type. + /// An array generator to constrain fluently. + /// Thrown when is null. + public static AnyArray ArrayOf(IAny item) { + if (item is null) { throw new ArgumentNullException(nameof(item)); } + + return new AnyArray(AnyDerivation.SourceOf(item), CollectionState.Create(item, false, null)); + } + + /// + /// Starts an arbitrary generator over — same constraint + /// surface as . The generated sequence is fully materialized, so it never re-draws when + /// enumerated more than once. + /// + /// The generator each element is drawn from. + /// The element type. + /// A sequence generator to constrain fluently. + /// Thrown when is null. + public static AnySequence SequenceOf(IAny item) { + if (item is null) { throw new ArgumentNullException(nameof(item)); } + + return new AnySequence(AnyDerivation.SourceOf(item), CollectionState.Create(item, false, null)); + } + + /// + /// Starts an arbitrary generator over — distinct by nature. + /// When the count exceeds the number of distinct values can produce, the conflict is + /// reported eagerly. + /// + /// The generator each element is drawn from. + /// The element type. + /// A set generator to constrain fluently. + /// Thrown when is null. + public static AnySet SetOf(IAny item) { + if (item is null) { throw new ArgumentNullException(nameof(item)); } + + return new AnySet(AnyDerivation.SourceOf(item), CollectionState.Create(item, true, null)); + } + + /// + /// Starts an arbitrary generator over , deduplicating + /// elements with — the same comparer the resulting set carries. + /// + /// The generator each element is drawn from. + /// The equality comparer deciding whether two elements are the same. + /// The element type. + /// A set generator to constrain fluently. + /// Thrown when or is null. + public static AnySet SetOf(IAny item, IEqualityComparer comparer) { + if (item is null) { throw new ArgumentNullException(nameof(item)); } + if (comparer is null) { throw new ArgumentNullException(nameof(comparer)); } + + return new AnySet(AnyDerivation.SourceOf(item), CollectionState.Create(item, true, comparer)); + } + + /// + /// Starts an arbitrary generator drawing keys from + /// and values from . Keys are distinct by nature, so the key + /// generator's domain gates feasibility exactly as it does for . + /// + /// The generator each key is drawn from. + /// The generator each value is drawn from. + /// The key type. + /// The value type. + /// A dictionary generator to constrain fluently. + /// Thrown when or is null. + public static AnyDictionary DictionaryOf(IAny keys, IAny values) + where TKey : notnull { + if (keys is null) { throw new ArgumentNullException(nameof(keys)); } + if (values is null) { throw new ArgumentNullException(nameof(values)); } + + RandomSource? source = AnyDerivation.SourceOf(keys) ?? AnyDerivation.SourceOf(values); + + return new AnyDictionary(source, CollectionState.Create(keys, true, null), values); + } + + /// + /// Starts an arbitrary generator whose keys are deduplicated with + /// — the same comparer the resulting dictionary carries. + /// + /// The generator each key is drawn from. + /// The generator each value is drawn from. + /// The equality comparer deciding whether two keys are the same. + /// The key type. + /// The value type. + /// A dictionary generator to constrain fluently. + /// Thrown when any argument is null. + public static AnyDictionary DictionaryOf(IAny keys, IAny values, IEqualityComparer keyComparer) + where TKey : notnull { + if (keys is null) { throw new ArgumentNullException(nameof(keys)); } + if (values is null) { throw new ArgumentNullException(nameof(values)); } + if (keyComparer is null) { throw new ArgumentNullException(nameof(keyComparer)); } + + RandomSource? source = AnyDerivation.SourceOf(keys) ?? AnyDerivation.SourceOf(values); + + return new AnyDictionary(source, CollectionState.Create(keys, true, keyComparer), values); + } + + /// + /// Composes two generators into a generator of the value tuple (, + /// ) — sugar over for the common case of + /// pairing two arbitrary values. + /// + /// The generator of the first component. + /// The generator of the second component. + /// The type of the first component. + /// The type of the second component. + /// A generator of the paired value. + /// Thrown when any argument is null. + public static IAny<(T1, T2)> PairOf(IAny first, IAny second) { + return Combine(first, second, (one, two) => (one, two)); + } + + /// + /// Composes three generators into a generator of the value tuple (, + /// , ) — sugar over + /// . + /// + /// The generator of the first component. + /// The generator of the second component. + /// The generator of the third component. + /// The type of the first component. + /// The type of the second component. + /// The type of the third component. + /// A generator of the tripled value. + /// Thrown when any argument is null. + public static IAny<(T1, T2, T3)> TripleOf(IAny first, IAny second, IAny third) { + return Combine(first, second, third, (one, two, three) => (one, two, three)); + } + private static void Report(Action? report, int seed) { (report ?? Console.Error.WriteLine)( $"[Dummies] These arbitrary values were seeded with {seed}. Reproduce this run with Any.Reproducibly({seed}, ...)."); diff --git a/Dummies/AnyArray.cs b/Dummies/AnyArray.cs new file mode 100644 index 00000000..8eafc1e5 --- /dev/null +++ b/Dummies/AnyArray.cs @@ -0,0 +1,53 @@ +namespace Dummies; + +/// +/// A fluent generator of arbitrary array (T[]) values over an element generator. Shares the collection +/// constraint surface () — count bounds and contained values — +/// and adds to require pairwise-distinct elements. +/// +/// The element type. +public sealed class AnyArray : AnyCollection> { + + #region Statics members declarations + + /// + /// Generates the value — an can be used wherever a T[] is expected. Each + /// conversion draws a fresh array. + /// + /// The generator to draw from. + /// An arbitrary array satisfying the generator's constraints. + public static implicit operator T[](AnyArray generator) { + return generator.Generate(); + } + + #endregion + + internal AnyArray(RandomSource? source, CollectionState state) : base(source, state) { } + + /// Requires the elements to be pairwise distinct (default equality). + /// A new generator carrying the added constraint. + /// Thrown when the constraint cannot be satisfied by the element generator's domain. + public AnyArray Distinct() { + return With(State.AsDistinct(null, "Distinct()")); + } + + /// Requires the elements to be pairwise distinct under . + /// The equality comparer deciding whether two elements are the same. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when the constraint cannot be satisfied by the element generator's domain. + public AnyArray Distinct(IEqualityComparer comparer) { + if (comparer is null) { throw new ArgumentNullException(nameof(comparer)); } + + return With(State.AsDistinct(comparer, "Distinct(comparer)")); + } + + private protected override AnyArray With(CollectionState state) { + return new AnyArray(SourceOrNull, state); + } + + private protected override T[] Build(List items) { + return items.ToArray(); + } + +} diff --git a/Dummies/AnyBool.cs b/Dummies/AnyBool.cs index 6ce6c277..cecf0359 100644 --- a/Dummies/AnyBool.cs +++ b/Dummies/AnyBool.cs @@ -5,7 +5,7 @@ namespace Dummies; /// mostly useful for symmetry when a test sweeps cases — and contradictory pins fail eagerly with a /// naming both sides, like every other generator. /// -public sealed class AnyBool : IAny, IHasRandomSource { +public sealed class AnyBool : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -45,6 +45,9 @@ private AnyBool(RandomSource source, bool? pinned, string? pinnedConstraint) { RandomSource? IHasRandomSource.Source => _source; + // Two distinct values unless a pin has already fixed one of them. + long? ICardinalityHint.DistinctCardinality => _pinned is null ? 2 : 1; + /// Pins the value to true. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyByte.cs b/Dummies/AnyByte.cs index c969cc63..6b3a28f2 100644 --- a/Dummies/AnyByte.cs +++ b/Dummies/AnyByte.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyByte : IAny, IHasRandomSource { +public sealed class AnyByte : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -62,6 +62,8 @@ private AnyByte(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyChar.cs b/Dummies/AnyChar.cs index 3486a5de..9d0c5cd0 100644 --- a/Dummies/AnyChar.cs +++ b/Dummies/AnyChar.cs @@ -8,7 +8,7 @@ namespace Dummies; /// . A combination that empties the pool fails eagerly with a /// . /// -public sealed class AnyChar : IAny, IHasRandomSource { +public sealed class AnyChar : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -72,6 +72,9 @@ private AnyChar(RandomSource source, RandomSource? IHasRandomSource.Source => _source; + // The pool is materialized once at construction, so its size is the exact number of characters drawable. + long? ICardinalityHint.DistinctCardinality => _pool.Count; + /// Restricts the character to ASCII letters only. Declared once per generator. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyCollection.cs b/Dummies/AnyCollection.cs new file mode 100644 index 00000000..ffb7be92 --- /dev/null +++ b/Dummies/AnyCollection.cs @@ -0,0 +1,158 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + +namespace Dummies; + +/// +/// The shared fluent surface of the collection generators. Every collection — a , +/// an , an or an — carries a +/// count and, optionally, values it must contain; the concrete generators add only how the elements are shaped +/// (a set is always distinct) and what type returns. +/// +/// +/// The contract matches the scalar generators: constraints express what the surrounding code requires of +/// the collection, never what the test asserts; instances are immutable recipes, each method returning a new +/// generator; and a combination that cannot be satisfied fails eagerly with a +/// naming both sides. Unconstrained, a collection holds 0 to 8 +/// elements — chain when the surrounding code requires content. +/// +/// The element type. +/// The collection type produces. +/// The concrete generator type, so the fluent methods return it. +public abstract class AnyCollection : IAny, IHasRandomSource + where TSelf : AnyCollection { + + #region Statics members declarations + + private static string V(int value) { + return value.ToString(CultureInfo.InvariantCulture); + } + + private static int RequireNonNegative(int count, string parameterName) { + if (count < 0) { throw new ArgumentOutOfRangeException(parameterName, count, "The count must not be negative."); } + + return count; + } + + #endregion + + #region Fields declarations + + private protected readonly RandomSource? SourceOrNull; + private protected readonly CollectionState State; + + #endregion + + private protected AnyCollection(RandomSource? source, CollectionState state) { + SourceOrNull = source; + State = state; + } + + RandomSource? IHasRandomSource.Source => SourceOrNull; + + /// Requires at least one element. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public TSelf NonEmpty() { + return With(State.WithMinCount(1, "NonEmpty()")); + } + + /// Fixes the collection to no elements. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public TSelf Empty() { + return With(State.WithExactCount(0, "Empty()")); + } + + /// Fixes the exact number of elements. Declared once per generator. + /// The exact number of elements. + /// A new generator carrying the added constraint. + /// Thrown when is negative. + /// Thrown when the constraint contradicts a constraint already declared. + public TSelf WithCount(int count) { + RequireNonNegative(count, nameof(count)); + + return With(State.WithExactCount(count, $"WithCount({V(count)})")); + } + + /// Requires at least elements. + /// The inclusive minimum number of elements. + /// A new generator carrying the added constraint. + /// Thrown when is negative. + /// Thrown when the constraint contradicts a constraint already declared. + public TSelf WithMinCount(int count) { + RequireNonNegative(count, nameof(count)); + + return With(State.WithMinCount(count, $"WithMinCount({V(count)})")); + } + + /// Requires at most elements. + /// The inclusive maximum number of elements. + /// A new generator carrying the added constraint. + /// Thrown when is negative. + /// Thrown when the constraint contradicts a constraint already declared. + public TSelf WithMaxCount(int count) { + RequireNonNegative(count, nameof(count)); + + return With(State.WithMaxCount(count, $"WithMaxCount({V(count)})")); + } + + /// Requires a number of elements within the inclusive range [, ]. + /// The inclusive minimum number of elements. + /// The inclusive maximum number of elements. + /// A new generator carrying the added constraint. + /// Thrown when a bound is negative. + /// Thrown when is greater than . + /// Thrown when the constraint contradicts a constraint already declared. + public TSelf WithCountBetween(int minimum, int maximum) { + RequireNonNegative(minimum, nameof(minimum)); + RequireNonNegative(maximum, nameof(maximum)); + if (minimum > maximum) { throw new ArgumentException($"The minimum ({V(minimum)}) must be less than or equal to the maximum ({V(maximum)}).", nameof(minimum)); } + + string constraint = $"WithCountBetween({V(minimum)}, {V(maximum)})"; + + return With(State.WithMinCount(minimum, constraint).WithMaxCount(maximum, constraint)); + } + + /// + /// Requires the collection to contain . May be declared several times; each required + /// value takes one element's room. In a distinct collection the required values must themselves be distinct. + /// + /// The value the generated collection must contain. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public TSelf Containing(TItem value) { + return With(State.WithContaining(value, $"Containing({AnyDerivation.Display(value)})")); + } + + /// + /// Requires the collection to contain a value drawn from at generation time — + /// useful to force a particular shape of element into an otherwise arbitrary collection. Named apart from + /// because a library generator both is an and converts + /// implicitly to its value, which would make a single overloaded name ambiguous. + /// + /// The generator whose drawn value the collection must contain. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when the constraint contradicts a constraint already declared. + public TSelf ContainingAny(IAny generator) { + if (generator is null) { throw new ArgumentNullException(nameof(generator)); } + + return With(State.WithContaining(generator, "ContainingAny()")); + } + + /// + public TResult Generate() { + return Build(State.Materialize(SourceOrNull ?? AmbientRandomSource.Instance)); + } + + /// Wraps a new state in the concrete generator type. + private protected abstract TSelf With(CollectionState state); + + /// Converts the materialized elements into the concrete collection type. + private protected abstract TResult Build(List items); + +} diff --git a/Dummies/AnyDateOnly.cs b/Dummies/AnyDateOnly.cs index 204dcc59..82bc4abc 100644 --- a/Dummies/AnyDateOnly.cs +++ b/Dummies/AnyDateOnly.cs @@ -16,7 +16,7 @@ namespace Dummies; /// constraint: a reproducible test pins its reference dates explicitly with and /// . /// -public sealed class AnyDateOnly : IAny, IHasRandomSource { +public sealed class AnyDateOnly : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -66,6 +66,8 @@ private AnyDateOnly(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Requires a date strictly after . /// The exclusive lower bound. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyDateTime.cs b/Dummies/AnyDateTime.cs index 9a24e2c4..1e657101 100644 --- a/Dummies/AnyDateTime.cs +++ b/Dummies/AnyDateTime.cs @@ -19,7 +19,7 @@ namespace Dummies; /// comparison operators do. Values supplied to are returned as given, Kind included. There is deliberately no clock-relative constraint (no "in the past/future"): a /// reproducible test pins its reference instants explicitly with and . /// -public sealed class AnyDateTime : IAny, IHasRandomSource { +public sealed class AnyDateTime : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -71,6 +71,8 @@ private AnyDateTime(RandomSource source, OrdinalIntervalSpec spec, IReadOnlyDict RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Requires an instant strictly after . /// The exclusive lower bound. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyDateTimeOffset.cs b/Dummies/AnyDateTimeOffset.cs index 0d60c069..146758cb 100644 --- a/Dummies/AnyDateTimeOffset.cs +++ b/Dummies/AnyDateTimeOffset.cs @@ -21,7 +21,7 @@ namespace Dummies; /// past/future"): a reproducible test pins its reference instants explicitly with and /// . /// -public sealed class AnyDateTimeOffset : IAny, IHasRandomSource { +public sealed class AnyDateTimeOffset : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -73,6 +73,8 @@ private AnyDateTimeOffset(RandomSource source, OrdinalIntervalSpec spec, IReadOn RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Requires an instant strictly after . /// The exclusive lower bound. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyDerivation.cs b/Dummies/AnyDerivation.cs index e7428433..6663bdcb 100644 --- a/Dummies/AnyDerivation.cs +++ b/Dummies/AnyDerivation.cs @@ -43,6 +43,14 @@ internal static class AnyDerivation { return (generator as IHasRandomSource)?.Source; } + /// + /// A conservative upper bound on the number of distinct values yields, when it + /// advertises one through ; null when the domain is unbounded or unknown. + /// + internal static long? CardinalityOf(IAny generator) { + return (generator as ICardinalityHint)?.DistinctCardinality; + } + /// /// Runs a user-supplied factory or composer and converts its failure into an /// that names the generated value(s) and, when the random context is diff --git a/Dummies/AnyDictionary.cs b/Dummies/AnyDictionary.cs new file mode 100644 index 00000000..591144a9 --- /dev/null +++ b/Dummies/AnyDictionary.cs @@ -0,0 +1,138 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + +namespace Dummies; + +/// +/// A fluent generator of arbitrary values over a key generator and a value +/// generator. The keys are distinct by nature — the count constraints bound the number of entries, and the key +/// generator's domain gates feasibility exactly as it does for a : too small a key domain +/// for the requested count fails eagerly with a , a genuine +/// shortfall surfaces at generation as an . +/// +/// The key type. +/// The value type. +public sealed class AnyDictionary : IAny>, IHasRandomSource + where TKey : notnull { + + #region Statics members declarations + + /// + /// Generates the value — an can be used wherever a + /// is expected. Each conversion draws a fresh dictionary. + /// + /// The generator to draw from. + /// An arbitrary dictionary satisfying the generator's constraints. + public static implicit operator Dictionary(AnyDictionary generator) { + return generator.Generate(); + } + + private static string V(int value) { + return value.ToString(CultureInfo.InvariantCulture); + } + + private static int RequireNonNegative(int count, string parameterName) { + if (count < 0) { throw new ArgumentOutOfRangeException(parameterName, count, "The count must not be negative."); } + + return count; + } + + #endregion + + #region Fields declarations + + private readonly CollectionState _keys; + private readonly RandomSource? _source; + private readonly IAny _values; + + #endregion + + internal AnyDictionary(RandomSource? source, CollectionState keys, IAny values) { + _source = source; + _keys = keys; + _values = values; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// Requires at least one entry. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDictionary NonEmpty() { + return With(_keys.WithMinCount(1, "NonEmpty()")); + } + + /// Fixes the dictionary to no entries. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDictionary Empty() { + return With(_keys.WithExactCount(0, "Empty()")); + } + + /// Fixes the exact number of entries. Declared once per generator. + /// The exact number of entries. + /// A new generator carrying the added constraint. + /// Thrown when is negative. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDictionary WithCount(int count) { + RequireNonNegative(count, nameof(count)); + + return With(_keys.WithExactCount(count, $"WithCount({V(count)})")); + } + + /// Requires at least entries. + /// The inclusive minimum number of entries. + /// A new generator carrying the added constraint. + /// Thrown when is negative. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDictionary WithMinCount(int count) { + RequireNonNegative(count, nameof(count)); + + return With(_keys.WithMinCount(count, $"WithMinCount({V(count)})")); + } + + /// Requires at most entries. + /// The inclusive maximum number of entries. + /// A new generator carrying the added constraint. + /// Thrown when is negative. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDictionary WithMaxCount(int count) { + RequireNonNegative(count, nameof(count)); + + return With(_keys.WithMaxCount(count, $"WithMaxCount({V(count)})")); + } + + /// Requires a number of entries within the inclusive range [, ]. + /// The inclusive minimum number of entries. + /// The inclusive maximum number of entries. + /// A new generator carrying the added constraint. + /// Thrown when a bound is negative. + /// Thrown when is greater than . + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDictionary WithCountBetween(int minimum, int maximum) { + RequireNonNegative(minimum, nameof(minimum)); + RequireNonNegative(maximum, nameof(maximum)); + if (minimum > maximum) { throw new ArgumentException($"The minimum ({V(minimum)}) must be less than or equal to the maximum ({V(maximum)}).", nameof(minimum)); } + + string constraint = $"WithCountBetween({V(minimum)}, {V(maximum)})"; + + return With(_keys.WithMinCount(minimum, constraint).WithMaxCount(maximum, constraint)); + } + + /// + public Dictionary Generate() { + List keys = _keys.Materialize(_source ?? AmbientRandomSource.Instance); + Dictionary dictionary = new(keys.Count, _keys.Comparer); + foreach (TKey key in keys) { dictionary[key] = _values.Generate(); } + + return dictionary; + } + + private AnyDictionary With(CollectionState keys) { + return new AnyDictionary(_source, keys, _values); + } + +} diff --git a/Dummies/AnyEnum.cs b/Dummies/AnyEnum.cs index d8a53464..0c3e21cc 100644 --- a/Dummies/AnyEnum.cs +++ b/Dummies/AnyEnum.cs @@ -7,7 +7,7 @@ namespace Dummies; /// fails eagerly with a naming both sides. /// /// The enum type to draw values from. -public sealed class AnyEnum : IAny, IHasRandomSource +public sealed class AnyEnum : IAny, IHasRandomSource, ICardinalityHint where TEnum : struct, Enum { #region Statics members declarations @@ -68,6 +68,9 @@ private AnyEnum(RandomSource source, IReadOnlyList declared, RandomSource? IHasRandomSource.Source => _source; + // The pool is materialized once at construction, so its size is the exact number of members drawable. + long? ICardinalityHint.DistinctCardinality => _pool.Count; + /// 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. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyGenerationException.cs b/Dummies/AnyGenerationException.cs index 4b4d7e52..fa90de27 100644 --- a/Dummies/AnyGenerationException.cs +++ b/Dummies/AnyGenerationException.cs @@ -32,6 +32,10 @@ internal AnyGenerationException(string message, int? seed, Exception innerExcept Seed = seed; } + internal AnyGenerationException(string message, int? seed) : base(message) { + Seed = seed; + } + /// /// The seed of the random context the failing generation drew from, when it is known — pass it to /// Any.Reproducibly(seed, ...) to replay the run. null when the failing generator does not draw diff --git a/Dummies/AnyGuid.cs b/Dummies/AnyGuid.cs index 2bd8b477..687c2eef 100644 --- a/Dummies/AnyGuid.cs +++ b/Dummies/AnyGuid.cs @@ -8,7 +8,7 @@ namespace Dummies; /// to pin the empty identifier. Contradictory constraints fail eagerly with a /// naming both sides. /// -public sealed class AnyGuid : IAny, IHasRandomSource { +public sealed class AnyGuid : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -62,6 +62,9 @@ private AnyGuid(RandomSource source, Guid? pinned, string? pinnedConstraint, RandomSource? IHasRandomSource.Source => _source; + // Pinned to a single value, or bounded by an allow-list; otherwise the domain is effectively unbounded. + long? ICardinalityHint.DistinctCardinality => _pinned is not null ? 1 : _effectiveAllowed?.Count; + /// Requires an identifier different from . /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyInt16.cs b/Dummies/AnyInt16.cs index b47188f3..48008939 100644 --- a/Dummies/AnyInt16.cs +++ b/Dummies/AnyInt16.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyInt16 : IAny, IHasRandomSource { +public sealed class AnyInt16 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -62,6 +62,8 @@ private AnyInt16(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyInt32.cs b/Dummies/AnyInt32.cs index f6718454..9551a6d4 100644 --- a/Dummies/AnyInt32.cs +++ b/Dummies/AnyInt32.cs @@ -28,7 +28,7 @@ namespace Dummies; /// /// /// -public sealed class AnyInt32 : IAny, IHasRandomSource { +public sealed class AnyInt32 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -78,6 +78,8 @@ private AnyInt32(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyInt64.cs b/Dummies/AnyInt64.cs index 77b99519..548317b6 100644 --- a/Dummies/AnyInt64.cs +++ b/Dummies/AnyInt64.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyInt64 : IAny, IHasRandomSource { +public sealed class AnyInt64 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -62,6 +62,8 @@ private AnyInt64(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyList.cs b/Dummies/AnyList.cs new file mode 100644 index 00000000..df85f0f5 --- /dev/null +++ b/Dummies/AnyList.cs @@ -0,0 +1,53 @@ +namespace Dummies; + +/// +/// A fluent generator of arbitrary values over an element generator. Shares the collection +/// constraint surface () — count bounds and contained values — +/// and adds to require pairwise-distinct elements. +/// +/// The element type. +public sealed class AnyList : AnyCollection, AnyList> { + + #region Statics members declarations + + /// + /// Generates the value — an can be used wherever a is expected. + /// Each conversion draws a fresh list. + /// + /// The generator to draw from. + /// An arbitrary list satisfying the generator's constraints. + public static implicit operator List(AnyList generator) { + return generator.Generate(); + } + + #endregion + + internal AnyList(RandomSource? source, CollectionState state) : base(source, state) { } + + /// Requires the elements to be pairwise distinct (default equality). + /// A new generator carrying the added constraint. + /// Thrown when the constraint cannot be satisfied by the element generator's domain. + public AnyList Distinct() { + return With(State.AsDistinct(null, "Distinct()")); + } + + /// Requires the elements to be pairwise distinct under . + /// The equality comparer deciding whether two elements are the same. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when the constraint cannot be satisfied by the element generator's domain. + public AnyList Distinct(IEqualityComparer comparer) { + if (comparer is null) { throw new ArgumentNullException(nameof(comparer)); } + + return With(State.AsDistinct(comparer, "Distinct(comparer)")); + } + + private protected override AnyList With(CollectionState state) { + return new AnyList(SourceOrNull, state); + } + + private protected override List Build(List items) { + return items; + } + +} diff --git a/Dummies/AnySByte.cs b/Dummies/AnySByte.cs index 6f66f0a1..ca39c444 100644 --- a/Dummies/AnySByte.cs +++ b/Dummies/AnySByte.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnySByte : IAny, IHasRandomSource { +public sealed class AnySByte : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -62,6 +62,8 @@ private AnySByte(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnySequence.cs b/Dummies/AnySequence.cs new file mode 100644 index 00000000..6d828cd2 --- /dev/null +++ b/Dummies/AnySequence.cs @@ -0,0 +1,47 @@ +namespace Dummies; + +/// +/// A fluent generator of arbitrary values over an element generator. The generated +/// sequence is fully materialized: it never defers work, so enumerating it twice yields the same elements +/// and never re-draws. Shares the collection constraint surface +/// () — count bounds and contained values — and adds +/// to require pairwise-distinct elements. +/// +/// +/// Unlike the other collection generators, an exposes no implicit conversion: +/// C# forbids a user-defined conversion whose target is an interface such as . Call +/// , or use the generator through +/// . +/// +/// The element type. +public sealed class AnySequence : AnyCollection, AnySequence> { + + internal AnySequence(RandomSource? source, CollectionState state) : base(source, state) { } + + /// Requires the elements to be pairwise distinct (default equality). + /// A new generator carrying the added constraint. + /// Thrown when the constraint cannot be satisfied by the element generator's domain. + public AnySequence Distinct() { + return With(State.AsDistinct(null, "Distinct()")); + } + + /// Requires the elements to be pairwise distinct under . + /// The equality comparer deciding whether two elements are the same. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when the constraint cannot be satisfied by the element generator's domain. + public AnySequence Distinct(IEqualityComparer comparer) { + if (comparer is null) { throw new ArgumentNullException(nameof(comparer)); } + + return With(State.AsDistinct(comparer, "Distinct(comparer)")); + } + + private protected override AnySequence With(CollectionState state) { + return new AnySequence(SourceOrNull, state); + } + + private protected override IEnumerable Build(List items) { + return items; + } + +} diff --git a/Dummies/AnySet.cs b/Dummies/AnySet.cs new file mode 100644 index 00000000..99d29d2b --- /dev/null +++ b/Dummies/AnySet.cs @@ -0,0 +1,40 @@ +namespace Dummies; + +/// +/// A fluent generator of arbitrary values over an element generator. A set is +/// distinct by nature, so it carries the collection constraint surface +/// () — count bounds and contained values — without a +/// Distinct() toggle. When the element generator advertises fewer distinct values than the requested +/// count, the contradiction is caught eagerly with a ; otherwise a +/// genuine shortfall surfaces at generation as an . +/// +/// The element type. +public sealed class AnySet : AnyCollection, AnySet> { + + #region Statics members declarations + + /// + /// Generates the value — an can be used wherever a is + /// expected. Each conversion draws a fresh set. + /// + /// The generator to draw from. + /// An arbitrary set satisfying the generator's constraints. + public static implicit operator HashSet(AnySet generator) { + return generator.Generate(); + } + + #endregion + + internal AnySet(RandomSource? source, CollectionState state) : base(source, state) { } + + private protected override AnySet With(CollectionState state) { + return new AnySet(SourceOrNull, state); + } + + private protected override HashSet Build(List items) { + // The state already deduplicated under the comparer; the set carries the same comparer so later lookups + // by the caller behave identically. + return new HashSet(items, State.Comparer); + } + +} diff --git a/Dummies/AnyTimeOnly.cs b/Dummies/AnyTimeOnly.cs index 861f8ef9..9e5fa3cc 100644 --- a/Dummies/AnyTimeOnly.cs +++ b/Dummies/AnyTimeOnly.cs @@ -16,7 +16,7 @@ namespace Dummies; /// constraint: a reproducible test pins its reference time of days explicitly with and /// . /// -public sealed class AnyTimeOnly : IAny, IHasRandomSource { +public sealed class AnyTimeOnly : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -66,6 +66,8 @@ private AnyTimeOnly(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Requires a time of day strictly after . /// The exclusive lower bound. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyTimeSpan.cs b/Dummies/AnyTimeSpan.cs index b09656f5..455cee8f 100644 --- a/Dummies/AnyTimeSpan.cs +++ b/Dummies/AnyTimeSpan.cs @@ -13,7 +13,7 @@ namespace Dummies; /// naming both sides; instances are immutable recipes, and each value is built to satisfy the constraints in one /// draw. Unconstrained, it draws from the full range, negative durations included. /// -public sealed class AnyTimeSpan : IAny, IHasRandomSource { +public sealed class AnyTimeSpan : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -63,6 +63,8 @@ private AnyTimeSpan(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Requires a duration strictly greater than . /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyUInt16.cs b/Dummies/AnyUInt16.cs index 2664c6d6..41fe70f5 100644 --- a/Dummies/AnyUInt16.cs +++ b/Dummies/AnyUInt16.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyUInt16 : IAny, IHasRandomSource { +public sealed class AnyUInt16 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -62,6 +62,8 @@ private AnyUInt16(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyUInt32.cs b/Dummies/AnyUInt32.cs index 574ea193..d745d192 100644 --- a/Dummies/AnyUInt32.cs +++ b/Dummies/AnyUInt32.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyUInt32 : IAny, IHasRandomSource { +public sealed class AnyUInt32 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -62,6 +62,8 @@ private AnyUInt32(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyUInt64.cs b/Dummies/AnyUInt64.cs index d090de71..e3311ca6 100644 --- a/Dummies/AnyUInt64.cs +++ b/Dummies/AnyUInt64.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyUInt64 : IAny, IHasRandomSource { +public sealed class AnyUInt64 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -62,6 +62,8 @@ private AnyUInt64(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/CollectionState.cs b/Dummies/CollectionState.cs new file mode 100644 index 00000000..5586e3bc --- /dev/null +++ b/Dummies/CollectionState.cs @@ -0,0 +1,223 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + +namespace Dummies; + +/// +/// The immutable specification behind every collection generator (, +/// , , and, for its keys, +/// ): the element generator, a shared , whether +/// the collection must be distinct, an optional equality comparer, and the values it must contain. Every mutation +/// returns a new state and validates the whole eagerly — so a collection generator that exists can always +/// produce, laid out directly rather than generated-then-filtered. +/// +/// +/// Distinctness follows the two-layer contract the library commits to: when the element generator advertises a +/// small cardinality that a requested count would exceed, the conflict is +/// caught at declaration time (); otherwise the count is drawn and +/// the elements are filled by a bounded dedup-draw, and a genuine shortfall surfaces at generation as an +/// naming the seed to replay. +/// +/// The element type. +internal sealed class CollectionState { + + #region Statics members declarations + + internal static CollectionState Create(IAny item, bool distinct, IEqualityComparer? comparer) { + return new CollectionState(item, AnyDerivation.CardinalityOf(item), CountSpec.Unconstrained, distinct, comparer, + Array.Empty(), Array.Empty>()); + } + + private static string Elements(int count) { + return count == 1 ? "1 element" : $"{count.ToString(CultureInfo.InvariantCulture)} elements"; + } + + private static IReadOnlyList Append(IReadOnlyList list, TItem value) { + List copy = new(list) { value }; + + return copy; + } + + private static void Shuffle(List items, Random random) { + // Fisher-Yates: contained values and filler are appended in a fixed order, so a shuffle keeps a dummy + // collection from advertising a positional invariant a caller might accidentally rely on. + for (int index = items.Count - 1; index > 0; index--) { + int swap = random.Next(index + 1); + (items[index], items[swap]) = (items[swap], items[index]); + } + } + + #endregion + + #region Fields declarations + + private readonly IEqualityComparer? _comparer; + private readonly CountSpec _count; + private readonly bool _distinct; + private readonly IReadOnlyList _fixedContaining; + private readonly IReadOnlyList> _generatedContaining; + private readonly IAny _item; + private readonly long? _itemCardinality; + + #endregion + + private CollectionState(IAny item, long? itemCardinality, CountSpec count, bool distinct, + IEqualityComparer? comparer, + IReadOnlyList fixedContaining, IReadOnlyList> generatedContaining) { + _item = item; + _itemCardinality = itemCardinality; + _count = count; + _distinct = distinct; + _comparer = comparer; + _fixedContaining = fixedContaining; + _generatedContaining = generatedContaining; + } + + /// The equality comparer distinct collections deduplicate with, or null for the default. + internal IEqualityComparer? Comparer => _comparer; + + /// Fixes the exact element count. + internal CollectionState WithExactCount(int count, string applying) { + return Rebuild(_count.WithExactCount(count, applying), _distinct, _comparer, _fixedContaining, _generatedContaining, applying); + } + + /// Tightens the minimum element count. + internal CollectionState WithMinCount(int count, string applying) { + return Rebuild(_count.WithMinCount(count, applying), _distinct, _comparer, _fixedContaining, _generatedContaining, applying); + } + + /// Tightens the maximum element count. + internal CollectionState WithMaxCount(int count, string applying) { + return Rebuild(_count.WithMaxCount(count, applying), _distinct, _comparer, _fixedContaining, _generatedContaining, applying); + } + + /// Requires the elements to be pairwise distinct, optionally under . + internal CollectionState AsDistinct(IEqualityComparer? comparer, string applying) { + return Rebuild(_count, true, comparer ?? _comparer, _fixedContaining, _generatedContaining, applying); + } + + /// Requires the collection to contain . + internal CollectionState WithContaining(T value, string applying) { + return Rebuild(_count, _distinct, _comparer, Append(_fixedContaining, value), _generatedContaining, applying); + } + + /// Requires the collection to contain a value drawn from . + internal CollectionState WithContaining(IAny generator, string applying) { + return Rebuild(_count, _distinct, _comparer, _fixedContaining, Append(_generatedContaining, generator), applying); + } + + /// Builds one collection satisfying the whole specification — laid out directly, never generate-then-retry. + internal List Materialize(RandomSource source) { + Random random = source.Current.Random; + int required = _fixedContaining.Count + _generatedContaining.Count; + int? cap = _distinct ? CardinalityCap() : null; + int count = _count.Resolve(random, required, cap); + + if (!_distinct) { + List items = new(Math.Max(count, required)); + items.AddRange(_fixedContaining); + foreach (IAny generator in _generatedContaining) { items.Add(generator.Generate()); } + while (items.Count < count) { items.Add(_item.Generate()); } + Shuffle(items, random); + + return items; + } + + HashSet seen = new(_comparer ?? EqualityComparer.Default); + List ordered = new(count); + foreach (T value in _fixedContaining) { + if (seen.Add(value)) { ordered.Add(value); } + } + foreach (IAny generator in _generatedContaining) { + T value = DrawFresh(generator, seen, source, count); + seen.Add(value); + ordered.Add(value); + } + FillDistinct(ordered, seen, source, count); + Shuffle(ordered, random); + + return ordered; + } + + private CollectionState Rebuild(CountSpec count, bool distinct, IEqualityComparer? comparer, + IReadOnlyList fixedContaining, IReadOnlyList> generatedContaining, string applying) { + CollectionState candidate = new(_item, _itemCardinality, count, distinct, comparer, fixedContaining, generatedContaining); + candidate.Validate(applying); + + return candidate; + } + + private void Validate(string applying) { + int required = _fixedContaining.Count + _generatedContaining.Count; + _count.EnsureFits(required, applying); + + if (!_distinct) { return; } + + IEqualityComparer comparer = _comparer ?? EqualityComparer.Default; + for (int left = 0; left < _fixedContaining.Count; left++) { + for (int right = left + 1; right < _fixedContaining.Count; right++) { + if (comparer.Equals(_fixedContaining[left], _fixedContaining[right])) { + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because a distinct collection cannot contain {AnyDerivation.Display(_fixedContaining[left])} more than once."); + } + } + } + + if (_itemCardinality is long cardinality) { + int need = Math.Max(_count.Floor, required); + if (need > cardinality) { + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {Elements(need)} required to be distinct exceed the {cardinality.ToString(CultureInfo.InvariantCulture)} distinct value(s) the element generator can produce."); + } + } + } + + private int? CardinalityCap() { + return _itemCardinality is long cardinality && cardinality <= int.MaxValue ? (int)cardinality : null; + } + + private T DrawFresh(IAny generator, HashSet seen, RandomSource source, int target) { + int budget = ExhaustionBudget(target); + for (int collisions = 0;;) { + T value = generator.Generate(); + if (!seen.Contains(value)) { return value; } + if (++collisions > budget) { throw Exhausted(source, seen.Count, target, "a ContainingAny(...) generator"); } + } + } + + private void FillDistinct(List ordered, HashSet seen, RandomSource source, int target) { + int budget = ExhaustionBudget(target); + int collisions = 0; + while (ordered.Count < target) { + T value = _item.Generate(); + if (seen.Add(value)) { + ordered.Add(value); + collisions = 0; + } else if (++collisions > budget) { + throw Exhausted(source, ordered.Count, target, "the element generator"); + } + } + } + + private int ExhaustionBudget(int target) { + // A known finite domain gets a coupon-collector-generous ceiling; an unknown or huge one gets a fixed + // floor that collisions only reach if the domain is unexpectedly small (for example a comparer that + // merges most values). Either way the fill is bounded — never an unbounded retry loop. + long cardinality = _itemCardinality ?? long.MaxValue; + long bounded = cardinality <= 1_000_000L ? 64L * cardinality : 64L * target; + + return (int)Math.Min(Math.Max(bounded, 10_000L), int.MaxValue); + } + + private static AnyGenerationException Exhausted(RandomSource source, int reached, int target, string what) { + int? seed = source.Current.Seed; + string message = $"Could not generate a distinct collection of {Elements(target)}: {what} produced only {reached} distinct value(s) before the draw budget was exhausted. Loosen the count or widen the element generator's domain."; + if (seed is not null) { + message += $" The arbitrary values were seeded with {seed}; reproduce this run with Any.Reproducibly({seed}, ...)."; + } + + return new AnyGenerationException(message, seed); + } + +} diff --git a/Dummies/CountSpec.cs b/Dummies/CountSpec.cs new file mode 100644 index 00000000..2ec354fa --- /dev/null +++ b/Dummies/CountSpec.cs @@ -0,0 +1,145 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + +namespace Dummies; + +/// +/// The immutable count specification shared by every collection generator (, +/// , ...): a lower bound, an optional upper bound and an optional exact count — each +/// remembering the constraint that set it, so a conflict message can name both sides. It is the collection-count +/// analogue of 's length bounds: every mutation returns a new specification and +/// cross-validates the whole eagerly, so a collection generator that exists can always produce a count. +/// +/// +/// Unconstrained, a collection draws between 0 and elements: an +/// unconstrained collection can therefore be empty — chain NonEmpty() when the surrounding code requires +/// content. The spread is deliberately smaller than 's (which is 16): a collection's +/// elements are themselves generated values, heavier than a string's characters, so a smaller default keeps a +/// dummy collection cheap while still exercising the multi-element path. +/// +internal sealed class CountSpec { + + /// The number of extra elements an unconstrained collection may hold above its required minimum. + internal const int DefaultCountSpread = 8; + + #region Statics members declarations + + internal static readonly CountSpec Unconstrained = new(null, null, 0, null, null, null); + + private static string V(int value) { + return value.ToString(CultureInfo.InvariantCulture); + } + + private static string Elements(int count) { + return count == 1 ? "1 element" : $"{V(count)} elements"; + } + + #endregion + + #region Fields declarations + + private readonly int? _exact; + private readonly string? _exactConstraint; + private readonly int? _max; + private readonly string? _maxConstraint; + private readonly int _min; + private readonly string? _minConstraint; + + #endregion + + private CountSpec(int? exact, string? exactConstraint, + int min, string? minConstraint, + int? max, string? maxConstraint) { + _exact = exact; + _exactConstraint = exactConstraint; + _min = min; + _minConstraint = minConstraint; + _max = max; + _maxConstraint = maxConstraint; + } + + /// The smallest count the specification allows — the exact count when pinned, otherwise the lower bound. + internal int Floor => _exact ?? _min; + + /// The largest count the specification allows, or null when the upper bound is left open. + internal int? Ceiling => _exact ?? _max; + + /// Fixes the exact count; declared once per generator. + internal CountSpec WithExactCount(int count, string applying) { + if (_exactConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_exactConstraint} is already defined."); } + + return new CountSpec(count, applying, _min, _minConstraint, _max, _maxConstraint).Validated(applying); + } + + /// Tightens the minimum count; a looser bound than the current one is a no-op. + internal CountSpec WithMinCount(int count, string applying) { + if (count <= _min) { return this; } + + return new CountSpec(_exact, _exactConstraint, count, applying, _max, _maxConstraint).Validated(applying); + } + + /// Tightens the maximum count; a looser bound than the current one is a no-op. + internal CountSpec WithMaxCount(int count, string applying) { + if (_max is not null && count >= _max) { return this; } + + return new CountSpec(_exact, _exactConstraint, _min, _minConstraint, count, applying).Validated(applying); + } + + /// + /// Draws a count satisfying the specification. raises the floor to cover + /// elements the collection must contain (see ); lowers + /// the ceiling to the number of distinct values a distinct collection can hold. Both are already known to be + /// compatible with the declared bounds — the collection validates them eagerly before generation. + /// + internal int Resolve(Random random, int requiredMin, int? cap) { + if (_exact is int exact) { return exact; } + + int min = Math.Max(_min, requiredMin); + // Long arithmetic: a huge declared minimum must saturate instead of overflowing past int.MaxValue. + int max = _max ?? (int)Math.Min((long)min + DefaultCountSpread, int.MaxValue); + if (cap is int ceiling && ceiling < max) { max = ceiling; } + if (max < min) { max = min; } + + return min == max ? min : random.NextInt32Inclusive(min, max); + } + + /// + /// Ensures the collection may hold the elements it must contain; throws naming the + /// upper bound that leaves no room. Symmetric wording, so the message reads whether the last constraint applied + /// was the count cap or the containment requirement. + /// + internal void EnsureFits(int required, string applying) { + int? cap = _exact ?? _max; + if (cap is int ceiling && required > ceiling) { + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {Elements(required)} required to be contained cannot fit in a collection of at most {Elements(ceiling)}."); + } + } + + private CountSpec Validated(string applying) { + if (_exact is int exact) { + if (exact < _min) { + throw new ConflictingAnyConstraintException(applying == _exactConstraint + ? $"Cannot apply {applying} because {_minConstraint} already requires at least {Elements(_min)}." + : $"Cannot apply {applying} because {_exactConstraint} already fixes the count at {V(exact)}."); + } + + if (_max is int cappedAt && exact > cappedAt) { + throw new ConflictingAnyConstraintException(applying == _exactConstraint + ? $"Cannot apply {applying} because {_maxConstraint} already caps the count at {V(cappedAt)}." + : $"Cannot apply {applying} because {_exactConstraint} already fixes the count at {V(exact)}."); + } + } + + if (_max is int max && _min > max) { + throw new ConflictingAnyConstraintException(applying == _maxConstraint + ? $"Cannot apply {applying} because {_minConstraint} already requires at least {Elements(_min)}." + : $"Cannot apply {applying} because {_maxConstraint} already caps the count at {V(max)}."); + } + + return this; + } + +} diff --git a/Dummies/ICardinalityHint.cs b/Dummies/ICardinalityHint.cs new file mode 100644 index 00000000..54b64ac4 --- /dev/null +++ b/Dummies/ICardinalityHint.cs @@ -0,0 +1,23 @@ +namespace Dummies; + +/// +/// Implemented by the library's own generators that draw from a small, countable domain, so a distinct +/// collection (, ListOf(...).Distinct(), a dictionary's keys) can tell — at +/// declaration time — that it is being asked for more distinct elements than the element generator could ever +/// produce, and fail eagerly with a instead of only discovering +/// it while drawing. +/// +/// +/// The value is a conservative upper bound on the number of distinct elements the generator yields. A +/// generator whose domain is unbounded, effectively unbounded, or simply unknown (a foreign +/// , a derived generator) does not implement this interface — the collection then relies on +/// the bounded dedup-draw fallback, which surfaces a genuine shortfall as an . +/// Because the bound ignores any custom (which can only merge values, +/// never create new ones), it stays a sound upper bound under a comparer too. +/// +internal interface ICardinalityHint { + + /// The number of distinct values the generator can produce, or null when that is unbounded or unknown. + long? DistinctCardinality { get; } + +} diff --git a/Dummies/OrdinalIntervalSpec.cs b/Dummies/OrdinalIntervalSpec.cs index eeed43aa..cc0246cd 100644 --- a/Dummies/OrdinalIntervalSpec.cs +++ b/Dummies/OrdinalIntervalSpec.cs @@ -146,6 +146,22 @@ internal OrdinalIntervalSpec WithExcluded(ulong[] ordinals, string applying) { return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded), applying); } + /// + /// The number of distinct values the specification can produce, or null when that exceeds + /// (a range too wide to ever conflict with a collection count). Feeds + /// , so a distinct collection over a narrow integer range can fail eagerly. + /// + internal long? Cardinality { + get { + if (_effectiveAllowed is not null) { return _effectiveAllowed.Count; } + if (IsFullWidth()) { return null; } + + ulong count = _max - _min + 1UL - (ulong)_excludedInRange.Count; + + return count <= long.MaxValue ? (long)count : null; + } + } + /// Draws one ordinal satisfying the whole specification — built directly, never generate-then-retry. internal ulong GenerateOrdinal(Random random) { if (_effectiveAllowed is not null) { diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 71ae308f..855ad6c1 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -41,6 +41,11 @@ matter — and that is the point. - **Composition without reflection**: `.As(factory)` turns a constrained primitive into a domain value object; `Any.Combine(...)` assembles larger objects through constructor lambdas. +- **Collections over any element generator**: `Any.ListOf(item)`, `ArrayOf`, + `SequenceOf`, `SetOf` and `DictionaryOf`, constrained with + `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. - **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. From fef2b242a772f0f85c29b4be4c45cf882b98e697 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 08:49:51 +0000 Subject: [PATCH 2/2] docs(dummies): draft ADR-0013 for distinct-collection feasibility Record, as Proposed, how a distinct collection resolves a count against its element domain: eager ConflictingAnyConstraintException when the generator advertises a cardinality the count exceeds, and a bounded deduplicating draw (AnyGenerationException on shortfall, seed reported) when the domain is unbounded or unknowable. Includes the French translation and the index entry. Numbered 0013: ADR-0012 was taken by the binder-options decision merged to main in the meantime. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhPrBqngpiSwdsF4DWvNPw --- ...ons-by-cardinality-else-bounded-draw.fr.md | 144 ++++++++++++++++++ ...ctions-by-cardinality-else-bounded-draw.md | 133 ++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 3 files changed, 278 insertions(+) create mode 100644 doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md diff --git a/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md b/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md new file mode 100644 index 00000000..ab00ea09 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md @@ -0,0 +1,144 @@ +# ADR-0013 | Contrôler les collections distinctes par la cardinalité, sinon par un tirage borné + +🌍 🇬🇧 [English](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-18 +**Décideurs :** Reefact + +## Contexte + +`Dummies` porte un contrat fondateur : une contrainte exprime ce qu'une valeur +doit satisfaire, des contraintes contradictoires échouent au moment où elles sont +déclarées via une `ConflictingAnyConstraintException` nommant les deux côtés, et +une valeur est construite pour satisfaire ses contraintes en une seule passe — +jamais générée puis filtrée, et jamais derrière une boucle de réessai. + +L'incrément « collections » ajoute les collections distinctes : `SetOf`, +`ListOf(...).Distinct()` et consorts, ainsi que les clés d'un dictionnaire. Une +collection distincte de *N* éléments n'est satisfaisable que si son générateur +d'éléments peut produire au moins *N* valeurs distinctes, ce qui est une propriété +du domaine de ce générateur. + +Les générateurs d'éléments se répartissent en deux groupes. Certains tirent d'un +domaine petit et dénombrable : un booléen a deux valeurs, une énumération a ses +membres déclarés, un intervalle entier étroit ou un pool de caractères restreint a +une taille fixe. D'autres tirent d'un domaine effectivement non borné ou +simplement inconnaissable pour la collection : entiers non contraints, chaînes et +identifiants, et — de façon décisive — toute implémentation étrangère de +`IAny` ou tout générateur dérivé (`As`, `Combine`), qui ne porte aucune +information de domaine. `IAny` est une interface publique : la bibliothèque ne +peut donc pas supposer que tout générateur sache rapporter sa cardinalité. + +Un comparateur d'égalité personnalisé ne peut que fusionner des valeurs distinctes +en un nombre moindre de classes d'équivalence ; il ne peut jamais en créer de +nouvelles. + +## Décision + +Une collection distincte rejette, au moment de la déclaration, tout nombre +d'éléments qui dépasse la cardinalité annoncée par le générateur d'éléments, et +construit sinon ses éléments par un tirage dédupliquant borné qui échoue à la +génération, avec une graine rejouable, si le domaine des éléments se révèle trop +petit. + +## Justification + +* **Échouer tôt partout où le domaine est connaissable.** Le conflit à la + déclaration est la signature de la bibliothèque : un nombre qui dépasse un + domaine d'éléments dénombrable est une contradiction dans l'`Arrange` du test, et + il doit se lire comme telle, nommée des deux côtés, exactement comme tout conflit + scalaire — non pas surgir plus tard sous forme d'un échec d'exécution + déroutant. +* **Un tirage borné est la seule option honnête là où il ne l'est pas.** Comme la + cardinalité d'un générateur arbitraire est généralement inconnaissable, la seule + façon universelle d'obtenir *N* valeurs distinctes est de tirer et dédupliquer. + Garder ce tirage borné respecte le principe « pas de boucle de réessai » ; à + épuisement, il rapporte le manque réel via une `AnyGenerationException` nommant + la graine — le canal d'échec qu'utilise déjà un rejet de fabrique — plutôt que de + boucler. +* **La borne annoncée reste correcte sous un comparateur.** Puisqu'un comparateur + ne fait que fusionner des valeurs, la cardinalité annoncée reste une borne + *supérieure* valide : le contrôle anticipé ne rejette donc jamais une demande qui + était en réalité satisfaisable ; un comparateur qui réduit le domaine sous le + nombre demandé est rattrapé par le tirage borné. +* **Un seul principe, appliqué là où son information existe.** Répartir l'échec + entre la déclaration (quand le domaine est dénombrable) et la génération (quand + il ne l'est pas) n'est pas un affaiblissement du principe de conflit anticipé + mais son extension fidèle au seul endroit où l'information nécessaire pour être + anticipé fait défaut. + +## Alternatives considérées + +### Toujours échouer à la génération, en abandonnant le contrôle anticipé de cardinalité + +Considérée parce qu'un canal d'échec unique est plus simple à expliquer et à +implémenter. Rejetée parce qu'elle jette le diagnostic signature de la +bibliothèque précisément là où il est peu coûteux et certain — un ensemble de trois +booléens, une énumération à qui l'on demande plus de membres qu'elle n'en déclare — +transformant une contradiction évidente de l'`Arrange` en une surprise à +l'exécution. + +### Faire de la cardinalité une partie de `IAny`, pour trancher chaque demande tôt + +Considérée parce qu'une cardinalité obligatoire sur chaque générateur permettrait +de trancher toute demande distincte à la déclaration. Rejetée parce que `IAny` +est un contrat public, avec des implémentations étrangères et des générateurs +dérivés (`As`, `Combine`) incapables de rapporter honnêtement une borne ; la +garantie serait inapplicable et souvent fausse, et elle imposerait à chaque +implémenteur une valeur que la plupart ne peuvent pas fournir. + +### Tirer sans borne jusqu'à ce que *N* valeurs distinctes apparaissent + +Considérée parce qu'un tirage non borné se termine toujours quand la demande est +satisfaisable. Rejetée parce qu'il ne se termine jamais quand la demande ne l'est +*pas*, ce qui est exactement le cas que cette décision doit diagnostiquer : elle +transformerait une demande impossible en blocage au lieu d'une erreur, brisant le +principe de travail borné de la bibliothèque. + +## Conséquences + +### Positives + +* Le diagnostic signature à la déclaration atteint désormais les collections + distinctes partout où le domaine des éléments est dénombrable. +* Les demandes sur des domaines inconnus ou réduits par un comparateur échouent + toujours de façon sûre et reproductible, avec une graine à rejouer, jamais en + blocage. +* La capacité de cardinalité est interne et optionnelle : le contrat public + `IAny` reste inchangé et les générateurs étrangers continuent de fonctionner + tels quels. + +### Négatives + +* Le moment de l'échec n'est pas uniforme : la même contradiction logique surgit à + la déclaration pour un domaine connu-petit et à la génération pour un domaine + inconnaissable, ce que l'utilisateur doit comprendre. +* Le tirage borné s'exécute jusqu'à un budget choisi ; une demande poussée + pathologiquement près de la taille réelle d'un domaine inconnu pourrait en + principe échouer bien qu'elle fût satisfaisable — astronomiquement improbable pour + les collections de taille « dummy » que vise la bibliothèque. + +### Risques + +* **Indice surestimé** — un générateur pourrait annoncer une cardinalité plus + grande que les valeurs distinctes qu'il produit réellement, de sorte que le + contrôle anticipé manque un vrai conflit. Atténué parce que l'indice est défini + comme une borne supérieure et que le tirage borné rattrape tout manque résiduel à + la génération. +* **Mauvais réglage du budget** — un budget de tirage trop petit produirait des + échecs de génération fallacieux. Atténué en dimensionnant le budget sur une + cardinalité connue et en gardant un plancher généreux pour les domaines inconnus. + +## Actions de suivi + +* Documenter les deux canaux d'échec dans la documentation utilisateur une fois la + surface « collections » stabilisée. +* Réexaminer le budget de tirage si un usage réel fait un jour apparaître un + épuisement fallacieux. + +## Références + +* ADR-0011 — Héberger Dummies comme un paquet autonome dans ce dépôt. +* Le moteur de collection distincte et la capacité de cardinalité, dans le projet + `Dummies` (`CollectionState`, `ICardinalityHint`). diff --git a/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md b/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md new file mode 100644 index 00000000..6f3b88e5 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md @@ -0,0 +1,133 @@ +# ADR-0013 | Gate distinct collections by cardinality, otherwise by a bounded draw + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md) + +**Status:** Proposed +**Date:** 2026-07-18 +**Decision Makers:** Reefact + +## Context + +`Dummies` carries a core contract: a constraint expresses what a value must +satisfy, contradictory constraints fail at the moment they are declared with a +`ConflictingAnyConstraintException` naming both sides, and a value is built to +satisfy its constraints in a single pass — never generated then filtered, and +never behind a retry loop. + +The collection increment adds distinct collections: `SetOf`, `ListOf(...).Distinct()` +and the like, and a dictionary's keys. A distinct collection of *N* elements is +satisfiable only if its element generator can yield at least *N* distinct values, +which is a property of that generator's domain. + +Element generators fall into two groups. Some draw from a small, countable +domain: a boolean has two values, an enum has its declared members, a narrow +integer range or a restricted character pool has a fixed size. Others draw from a +domain that is effectively unbounded or simply unknowable to the collection: +unconstrained integers, strings and identifiers, and — decisively — any foreign +`IAny` implementation or any derived generator (`As`, `Combine`), which carries +no domain information at all. `IAny` is a public interface, so the library +cannot assume every generator can report its cardinality. + +A custom equality comparer can only merge distinct values into fewer equivalence +classes; it can never manufacture new ones. + +## Decision + +A distinct collection rejects, at declaration time, any element count that +exceeds the element generator's advertised cardinality, and otherwise builds its +elements by a bounded deduplicating draw that fails at generation, with a +replayable seed, if the element domain proves too small. + +## Rationale + +* **Fail eagerly wherever the domain is knowable.** The declaration-time conflict + is the library's signature: a count that exceeds a countable element domain is + a contradiction in the test's `Arrange`, and it must read as one, named on both + sides, exactly like every scalar conflict — not surface later as a puzzling + runtime failure. +* **A bounded draw is the only honest option where it is not.** Because an + arbitrary generator's cardinality is generally unknowable, the only universal + way to obtain *N* distinct values is to draw and deduplicate. Keeping that draw + bounded honours the no-retry-loop principle; on exhaustion it reports the real + shortfall as an `AnyGenerationException` naming the seed — the same failure + channel a factory rejection already uses — rather than looping. +* **The advertised bound stays sound under a comparer.** Since a comparer only + merges values, the advertised cardinality remains a valid *upper* bound, so the + eager check never rejects a request that was actually satisfiable; a comparer + that collapses the domain below the requested count is caught by the bounded + draw instead. +* **One principle, applied where its information exists.** Splitting the failure + between declaration time (when the domain is countable) and generation time + (when it is not) is not a dilution of the eager-conflict principle but its + faithful extension to the only place where the information needed to be eager is + absent. + +## Alternatives Considered + +### Always fail at generation, dropping the eager cardinality check + +Considered because a single failure channel is simpler to explain and to +implement. Rejected because it discards the library's signature diagnostic +precisely where it is cheap and certain — a set of three booleans, an enum asked +for more members than it declares — turning an obvious `Arrange` contradiction +into a runtime surprise. + +### Make cardinality part of `IAny`, so every request is decided eagerly + +Considered because a mandatory cardinality on every generator would let every +distinct request fail or pass at declaration time. Rejected because `IAny` is +a public contract with foreign implementations and with derived generators +(`As`, `Combine`) that cannot honestly report a bound; the guarantee would be +unenforceable and frequently wrong, and it would burden every implementer with a +value most of them cannot supply. + +### Draw without a bound until *N* distinct values appear + +Considered because an unbounded draw always terminates when the request is +satisfiable. Rejected because it never terminates when the request is *not* +satisfiable, which is exactly the case this decision must diagnose: it would turn +an impossible request into a hang instead of an error, breaking the library's +bounded-work principle. + +## Consequences + +### Positive + +* The signature declaration-time diagnostic now reaches distinct collections + wherever the element domain is countable. +* Requests over unknown or comparer-reduced domains still fail safely and + reproducibly, with a seed to replay, never as a hang. +* The cardinality capability is internal and opt-in, so the public `IAny` + contract is unchanged and foreign generators keep working unmodified. + +### Negative + +* Failure timing is not uniform: the same logical contradiction surfaces at + declaration for a known-small domain and at generation for an unknowable one, + which a user must understand. +* The bounded draw runs to a chosen budget; a request pushed pathologically close + to an unknown domain's true size could in principle fail although it was + satisfiable — astronomically unlikely for the dummy-sized collections the + library targets. + +### Risks + +* **Overstated hint** — a generator could advertise a cardinality larger than the + distinct values it truly yields, so the eager check misses a real conflict. + Mitigated because the hint is defined as an upper bound and the bounded draw + catches any residual shortfall at generation. +* **Budget mis-tuning** — too small a draw budget would yield spurious generation + failures. Mitigated by scaling the budget to a known cardinality and keeping a + generous floor for unknown domains. + +## Follow-up Actions + +* Document the two failure channels in the user documentation once the collection + surface stabilizes. +* Revisit the draw budget if real usage ever surfaces a spurious exhaustion. + +## References + +* ADR-0011 — Host Dummies as a standalone package in this repository. +* The distinct-collection engine and the cardinality capability, in the `Dummies` + project (`CollectionState`, `ICardinalityHint`). diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index f1ad0add..17220ad3 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -186,3 +186,4 @@ Optional supporting material: | [ADR-0010](0010-treat-gendocs-error-catalog-as-a-versioned-contract.md) | Treat GenDoc's error catalog as a versioned contract | Accepted | | [ADR-0011](0011-host-dummies-as-a-standalone-package.md) | Host Dummies as a standalone package in this repository | Proposed | | [ADR-0012](0012-fix-the-binder-options-before-binding-begins.md) | Fix the binder options before binding begins | Accepted | +| [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) | Gate distinct collections by cardinality, otherwise by a bounded draw | Proposed |