diff --git a/Dummies.UnitTests/AnyStringOneOfTests.cs b/Dummies.UnitTests/AnyStringOneOfTests.cs new file mode 100644 index 0000000..e2d3aa3 --- /dev/null +++ b/Dummies.UnitTests/AnyStringOneOfTests.cs @@ -0,0 +1,157 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +[TestSubject(typeof(AnyStringOneOf))] +public sealed class AnyStringOneOfTests { + + private const int SampleCount = 200; + + #region Statics members declarations + + private static IEnumerable Samples(IAny generator) { + for (int i = 0; i < SampleCount; i++) { + yield return generator.Generate(); + } + } + + #endregion + + [Fact(DisplayName = "OneOf draws only the supplied values.")] + public void DrawsOnlyTheSuppliedValues() { + string[] allowed = ["Apple", "Microsoft", "Google"]; + foreach (string value in Samples(Any.String().OneOf(allowed))) { + Check.That(allowed.Contains(value)).IsTrue(); + } + } + + [Fact(DisplayName = "OneOf eventually reaches every supplied value.")] + public void ReachesEverySuppliedValue() { + HashSet seen = new(Samples(Any.String().OneOf("EUR", "USD", "GBP"))); + + Check.That(seen).Contains("EUR", "USD", "GBP"); + } + + [Fact(DisplayName = "A single value pins the generated string.")] + public void SingleValueIsPinned() { + foreach (string value in Samples(Any.String().OneOf("SOLE"))) { + Check.That(value).IsEqualTo("SOLE"); + } + } + + [Fact(DisplayName = "OneOf varies from draw to draw when the set holds more than one value.")] + public void VariesAcrossDraws() { + HashSet seen = new(Samples(Any.String().OneOf("a", "b", "c", "d"))); + + Check.That(seen.Count).IsStrictlyGreaterThan(1); + } + + [Fact(DisplayName = "Duplicate values are collapsed: both distinct values are still drawn, nothing else.")] + public void DuplicatesAreCollapsed() { + HashSet seen = new(Samples(Any.String().OneOf("a", "a", "b"))); + + Check.That(seen).IsOnlyMadeOf("a", "b"); + Check.That(seen).Contains("a", "b"); + } + + [Fact(DisplayName = "An empty string is a legitimate member of the set.")] + public void EmptyStringIsAllowed() { + Check.That(Any.String().OneOf("").Generate()).IsEqualTo(string.Empty); + } + + [Fact(DisplayName = "OneOf is reproducible under a seed.")] + public void ReproducibleUnderASeed() { + string first = string.Join("|", Enumerable.Range(0, 20).Select(_ => Any.WithSeed(7).String().OneOf("a", "b", "c", "d").Generate())); + string second = string.Join("|", Enumerable.Range(0, 20).Select(_ => Any.WithSeed(7).String().OneOf("a", "b", "c", "d").Generate())); + + Check.That(second).IsEqualTo(first); + } + + [Fact(DisplayName = "OneOf composes into a value object through As.")] + public void ComposesThroughAs() { + IAny generator = Any.String().OneOf("ORD-12345678", "ORD-87654321").As(OrderReference.Create); + + for (int i = 0; i < SampleCount; i++) { + OrderReference reference = generator.Generate(); + Check.That(reference.Value).StartsWith("ORD-"); + Check.That(reference.Value.Length).IsEqualTo(12); + } + } + + [Fact(DisplayName = "OrNull makes the value set generator null about half the time, otherwise a member of the set.")] + public void OrNullIsSometimesNull() { + IAny generator = Any.WithSeed(20260721).String().OneOf("a", "b").OrNull(); + + List values = new(); + for (int i = 0; i < SampleCount; i++) { + values.Add(generator.Generate()); + } + + Check.That(values.Any(value => value is null)).IsTrue(); + Check.That(values.Where(value => value is not null)).IsOnlyMadeOf("a", "b"); + } + + [Fact(DisplayName = "A distinct set over OneOf is gated by the set's cardinality, both ways.")] + public void CardinalityGatesDistinctCollections() { + // Two distinct values cannot fill a set of three: caught eagerly, like any cardinality conflict. + Check.ThatCode(() => Any.SetOf(Any.String().OneOf("a", "b")).WithCount(3)).Throws(); + + // Within the domain it fills the set with the requested distinct values. + HashSet set = Any.SetOf(Any.String().OneOf("a", "b", "c")).WithCount(3).Generate(); + Check.That(set.Count).IsEqualTo(3); + Check.That(set).IsOnlyMadeOf("a", "b", "c"); + } + + [Fact(DisplayName = "OneOf after another constraint conflicts: the value set is terminal.")] + public void OneOfAfterAConstraintConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().NonEmpty().OneOf("a", "b")); + + Check.That(conflict.Message).Contains("OneOf"); + Check.That(conflict.Message).Contains("terminal"); + } + + [Fact(DisplayName = "OneOf after a length, shape or character constraint conflicts too, whatever the constraint.")] + public void OneOfAfterVariousConstraintsConflicts() { + Check.ThatCode(() => Any.String().WithLength(3).OneOf("abc")).Throws(); + Check.ThatCode(() => Any.String().StartingWith("ORD-").OneOf("ORD-12345678")).Throws(); + Check.ThatCode(() => Any.String().Numeric().OneOf("123")).Throws(); + Check.ThatCode(() => Any.String().WithMaxLength(10).OneOf("x")).Throws(); + } + + [Fact(DisplayName = "OneOf rejects null, empty, or null-containing value lists as arguments.")] + public void RejectsInvalidValueLists() { + Check.ThatCode(() => Any.String().OneOf()).Throws(); + Check.ThatCode(() => Any.String().OneOf(null!)).Throws(); + Check.ThatCode(() => Any.String().OneOf("a", null!)).Throws(); + } + + [Fact(DisplayName = "OneOf accepts a sequence, drawing only from its values.")] + public void AcceptsASequence() { + IEnumerable vendors = new List { "Apple", "Microsoft", "Google" }; + + HashSet seen = new(Samples(Any.String().OneOf(vendors))); + + Check.That(seen).IsOnlyMadeOf("Apple", "Microsoft", "Google"); + Check.That(seen.Count).IsStrictlyGreaterThan(1); + } + + [Fact(DisplayName = "The sequence overload validates null, empty and null elements like the params one.")] + public void SequenceOverloadValidates() { + Check.ThatCode(() => Any.String().OneOf((IEnumerable)null!)).Throws(); + Check.ThatCode(() => Any.String().OneOf(Enumerable.Empty())).Throws(); + Check.ThatCode(() => Any.String().OneOf(new List { "a", null! })).Throws(); + } + + [Fact(DisplayName = "The sequence overload is terminal too: it conflicts after another constraint.")] + public void SequenceOverloadIsTerminal() { + Check.ThatCode(() => Any.String().NonEmpty().OneOf(new List { "a", "b" })).Throws(); + } + +} diff --git a/Dummies/AnyString.cs b/Dummies/AnyString.cs index 2a67357..9c90762 100644 --- a/Dummies/AnyString.cs +++ b/Dummies/AnyString.cs @@ -202,6 +202,45 @@ public AnyString UpperCase() { return new AnyString(_source, _spec.WithCasing(LetterCasing.Upper, "UpperCase()")); } + /// + /// Draws the string from an explicit, fixed set of instead of shaping one — the + /// dummy for a value whose domain is a closed list the test does not assert on (a currency code, a well-known + /// name). This is a terminal constraint: the supplied values are the whole specification, so it does not + /// combine with the shape, length or character constraints — declare it directly on . + /// Duplicate values are collapsed; the generated string is one of the distinct values, drawn uniformly and + /// reproducibly under a seed. + /// + /// The values the generated string is drawn from; duplicates are ignored. + /// A terminal generator drawing from . + /// Thrown when is null. + /// Thrown when is empty or contains a null element. + /// Thrown when a constraint is already declared: a terminal value set cannot be combined with another constraint. + public AnyStringOneOf OneOf(params string[] 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)); } + if (values.Any(value => value is null)) { throw new ArgumentException("The values must not contain a null element; use OrNull() to make the whole generator nullable.", nameof(values)); } + if (!_spec.IsUnconstrained) { throw new ConflictingAnyConstraintException("Cannot apply OneOf(...) because it is a terminal specification: the supplied values are the whole specification and cannot be combined with the constraints already declared. Declare OneOf(...) directly on Any.String()."); } + + return new AnyStringOneOf(_source, values.Distinct(StringComparer.Ordinal).ToArray()); + } + + /// + /// Draws the string from an explicit, fixed set of — the + /// counterpart of , for a set already held as a + /// sequence (a list, a LINQ result, values loaded at test setup). Same terminal contract: the values are the + /// whole specification, duplicates collapse, and the draw is uniform and reproducible under a seed. + /// + /// The values the generated string is drawn from; duplicates are ignored. + /// A terminal generator drawing from . + /// Thrown when is null. + /// Thrown when is empty or contains a null element. + /// Thrown when a constraint is already declared: a terminal value set cannot be combined with another constraint. + public AnyStringOneOf OneOf(IEnumerable values) { + if (values is null) { throw new ArgumentNullException(nameof(values)); } + + return OneOf(values as string[] ?? values.ToArray()); + } + /// public string Generate() { return _spec.Generate(_source.Current.Random); diff --git a/Dummies/AnyStringOneOf.cs b/Dummies/AnyStringOneOf.cs new file mode 100644 index 0000000..dae0cc3 --- /dev/null +++ b/Dummies/AnyStringOneOf.cs @@ -0,0 +1,53 @@ +namespace Dummies; + +/// +/// A generator of arbitrary strings drawn from an explicit, fixed set of values — the dummy for a value +/// whose domain is a closed list a test does not assert on (a well-known company name, a currency code from a +/// short table, a status label). The set is the whole specification, so this is a terminal generator: +/// unlike it exposes no further shape, length or character constraints — whatever +/// matters goes into the values themselves. It still composes like any other generator: pipe it through +/// As(...) into a value object, make it optional with OrNull(), or fold it into Combine(...) +/// and the collection generators. +/// +/// +/// +/// Each draws one value uniformly from the set, from the generator's random context — +/// so a run is reproducible under a seed, exactly like every other generator. Duplicate values are collapsed, +/// so no value carries a heavier weight for being listed twice, and the number of distinct values is the exact +/// size of the domain a distinct collection (SetOf, a dictionary's keys) gates against. +/// +/// +/// +/// string vendor = Any.String().OneOf("Apple", "Microsoft", "Google").Generate(); +/// IAny<Currency> currency = Any.String().OneOf("EUR", "USD", "GBP").As(Currency.Create); +/// +/// +/// +public sealed class AnyStringOneOf : IAny, IHasRandomSource, ICardinalityHint { + + #region Fields declarations + + private readonly RandomSource _source; + private readonly IReadOnlyList _values; + + #endregion + + internal AnyStringOneOf(RandomSource source, IReadOnlyList values) { + _source = source; + _values = values; + } + + RandomSource? IHasRandomSource.Source => _source; + + // The value set is fixed and deduplicated at construction, so its count is the exact number of distinct strings + // drawable, and membership is a direct set lookup under the same ordinal comparison used to deduplicate. + long? ICardinalityHint.DistinctCardinality => _values.Count; + + bool ICardinalityHint.Contains(string value) => _values.Contains(value, StringComparer.Ordinal); + + /// + public string Generate() { + return _values[_source.Current.Random.Next(_values.Count)]; + } + +} diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 271b608..de2048c 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -35,6 +35,11 @@ matter — and that is the point. Home-grown (zero dependencies) over the regular subset of the pattern language; a non-regular construct (a lookaround, a backreference) is refused with a clear error rather than a silently non-matching value. +- **Strings from an explicit set**: `Any.String().OneOf("EUR", "USD", "GBP")` draws from + a fixed, closed list — the dummy for a value whose domain is a short enumeration (a + currency code, a well-known name). A *terminal* generator, like `StringMatching`: the + set is the whole specification, duplicates collapse, and the draw is uniform and + reproducible under a seed. - **Domain vocabulary where it belongs**: dates constrain with `After`/`Before`/`Between`, quantities with `Positive`/`Between`/`NonZero`, identities with `NonEmpty`/`DifferentFrom` — and deliberately no clock-relative diff --git a/Dummies/StringSpec.cs b/Dummies/StringSpec.cs index 753611b..1a201c1 100644 --- a/Dummies/StringSpec.cs +++ b/Dummies/StringSpec.cs @@ -84,6 +84,16 @@ private StringSpec(int? exactLength, string? exactConstraint, _casingConstraint = casingConstraint; } + /// + /// Whether no constraint has been declared yet — the pristine state a generator from + /// starts in, before any fluent constraint narrows it. Used to keep a terminal constraint (OneOf) from + /// being combined with the shaping ones. + /// + internal bool IsUnconstrained => + _exactLength is null && _minLength == 0 && _maxLength is null && + _prefix is null && _suffix is null && _fragments.Count == 0 && + _charset is null && _casing is null; + /// Fixes the exact length; declared once per generator. internal StringSpec WithExactLength(int length, string applying) { if (_exactConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_exactConstraint} is already defined."); } diff --git a/doc/handwritten/for-maintainers/adr/0030-draw-arbitrary-strings-from-an-explicit-terminal-set.fr.md b/doc/handwritten/for-maintainers/adr/0030-draw-arbitrary-strings-from-an-explicit-terminal-set.fr.md new file mode 100644 index 0000000..c480782 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0030-draw-arbitrary-strings-from-an-explicit-terminal-set.fr.md @@ -0,0 +1,140 @@ +# ADR-0030 | Tirer des chaînes arbitraires depuis un ensemble de valeurs explicite et terminal + +🌍 🇬🇧 [English](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-21 +**Décideurs :** Reefact + +## Contexte + +`Dummies` fournit des valeurs arbitraires mais valides, avec des contraintes qui expriment ce que le code environnant +exige d'une valeur. Un besoin récurrent est une valeur dont le domaine est une **liste fixe et fermée** que le test +n'assère pas — un code devise tiré d'une petite table, un libellé de statut, le nom d'une entreprise connue. La +bibliothèque génère des formes **structurelles** (une longueur, une famille de caractères, un motif régulier) ; elle +ne sait pas synthétiser un tel ensemble du monde réel, et c'est l'appelant qui détient les valeurs. + +Plusieurs faits établis cadrent le choix : + +* Les générateurs de scalaires et d'enums exposent déjà `OneOf(params T[])` — tirer uniformément dans une liste + explicite — mais il s'agit là d'une contrainte **composable** : elle restreint *au sein* de l'intervalle ou du pool + du type et se cross-valide avec les autres contraintes. `AnyString` n'a aucun `OneOf`. +* `Any.StringMatching` (ADR-0025) est un générateur **terminal** : le motif est toute la spécification, il n'expose + donc aucune contrainte de forme ou de longueur supplémentaire, tout en se composant via `As`, `OrNull`, `Combine` + et les générateurs de collections comme n'importe quel `IAny`. +* La surface de mise en forme d'une chaîne est bien plus large que l'intervalle d'un scalaire : préfixe, suffixe, + fragments contenus, famille de caractères, casse et longueur, chacun déjà cross-validé avec les autres. +* Les collections distinctes bornent à la déclaration selon la cardinalité annoncée par le générateur d'éléments + (ADR-0013), via l'interface interne `ICardinalityHint` ; un générateur qui n'en annonce pas retombe sur un + tirage dédupliquant borné. +* La bibliothèque puise dans une source unique seedable pour que tout run soit reproductible (ADR-0006), construit + les valeurs pour satisfaire les contraintes plutôt que de générer-puis-filtrer, et est livrée **sans aucune + dépendance runtime ni jeu de données** — son README liste « pas de fausses données réalistes (noms, e-mails, + adresses) » comme non-objectif explicite (ADR-0011). +* La forme d'appel demandée est `Any.String().OneOf(...)` — chaînée depuis le point d'entrée des chaînes. + +## Décision + +`Any.String().OneOf(...)` tire la chaîne dans un ensemble de valeurs explicite fourni par l'appelant, sous la forme +d'un générateur **terminal** — l'ensemble est toute la spécification et ne se combine pas avec les autres contraintes +de chaîne — plutôt que comme une contrainte composable à la manière du `OneOf` des générateurs de scalaires. + +## Justification + +* **Un ensemble terminal garde la surface petite et sans contradiction.** Réconcilier un ensemble de valeurs + explicite avec le préfixe, le suffixe, les fragments, la famille de caractères, la casse et la longueur d'une chaîne + multiplierait les combinaisons contradictoires et leurs messages de conflit, pour une combinaison dont personne n'a + besoin — un appelant qui fournit des valeurs littérales en fixe déjà la forme. Faire de l'ensemble toute la + spécification supprime cette classe entière d'un coup. `Any.StringMatching` a tranché de même, pour la même raison + (ADR-0025) ; s'aligner sur ce précédent garde les deux terminaux de chaîne cohérents. +* **Il reste sur `Any.String()` pour la découvrabilité, et reste honnête par l'échec précoce.** Un appelant part de + `Any.String()` et trouve `OneOf` à côté des autres façons d'obtenir une chaîne. La nature terminale est garantie de + deux façons : le générateur renvoyé ne porte aucune méthode de mise en forme, et déclarer `OneOf` après une autre + contrainte lève une `ConflictingAnyConstraintException` à la déclaration — la même règle « un Arrange impossible est + un défaut du test » que la bibliothèque applique à tout autre conflit. +* **Des valeurs fournies par l'appelant préservent l'identité de la bibliothèque.** Le contenu réaliste vit dans le + test du consommateur, pas dans le paquet : le non-objectif « pas de fausses données réalistes » tient, et aucun jeu + de données, dépendance ou appel réseau n'est introduit. `OneOf` est la réponse sans dépendance et déterministe à + « donne-moi une valeur plausible tirée d'un ensemble connu ». +* **Annoncer la cardinalité garde les collections distinctes précoces.** Un ensemble explicite est un petit domaine + dénombrable, donc le générateur implémente `ICardinalityHint` ; une collection distincte le borne à la + déclaration (ADR-0013), exactement comme sur `AnyChar` ou `AnyEnum`, au lieu de compter silencieusement sur le repli + par tirage dédupliquant borné. +* **La reproductibilité est préservée.** La valeur est un tirage uniforme dans l'ensemble dédupliqué, via la même + source seedable que tout autre générateur : un run se rejoue sous une graine (ADR-0006) ; dédupliquer empêche + qu'une valeur listée soit implicitement surpondérée. + +## Alternatives considérées + +### Un `OneOf` composable sur `AnyString`, comme les générateurs de scalaires + +Considérée par symétrie de surface avec `AnyInt32.OneOf` et ses pairs. Rejetée parce que les contraintes de mise en +forme d'une chaîne croisent un ensemble de valeurs explicite de multiples façons, chacune nécessitant sa propre +analyse de conflit précoce et son message, pour une combinaison dont un appelant fournissant des littéraux n'a jamais +besoin — la forme terminale supprime la classe entière, cohérente avec ADR-0025. + +### Une factory statique `Any.StringOneOf(...)` (ou `Any.OneOf(...)`), parallèle à `Any.StringMatching` + +Considérée parce qu'une factory statique est terminale dès le premier appel et esquive tout cas « une contrainte est +déjà déclarée ». Rejetée parce que la surface demandée et plus découvrable est `Any.String().OneOf(...)`, qui garde +les points d'entrée des chaînes ensemble ; le cas de la contrainte préalable est couvert par un conflit clair à la +déclaration, le mécanisme que la bibliothèque emploie déjà pour toute combinaison impossible. + +### Livrer des jeux de données réalistes curés (`Any.CompanyName()`, `Any.FirstName()`, ...) + +Considérée parce qu'elle répond directement à « donne-moi une valeur plausible ». Rejetée parce qu'elle contredit le +non-objectif affiché de ne livrer aucune fausse donnée réaliste, et ferait porter à la bibliothèque un jeu de données +ouvert qu'elle devrait maintenir, faire grossir et localiser ; le consommateur fournit l'ensemble et `OneOf` y tire à +la place. + +### Générer l'ensemble au premier run via un service externe et le mettre en cache + +Considérée comme un moyen de composer l'ensemble sans l'écrire à la main. Rejetée parce qu'elle ajouterait une +dépendance runtime et un premier run non déterministe et non hermétique à une bibliothèque dont l'identité est une +génération déterministe sans dépendance (ADR-0006, ADR-0011) ; composer l'ensemble est une préoccupation de temps de +conception, qui a sa place hors de la bibliothèque. + +## Conséquences + +### Positives + +* Une valeur dont le domaine est une liste courte et fermée devient un dummy d'une ligne, sans dépendance et + reproductible, qui se compose en objets-valeurs (`As`), en optionnels (`OrNull`) et en collections comme tout autre + générateur. +* La forme terminale garde la surface des chaînes petite et exempte d'une nouvelle classe de combinaisons de + contraintes contradictoires. +* Une collection distincte sur l'ensemble est bornée précocement par sa cardinalité, cohérente avec les autres + générateurs à domaine dénombrable. + +### Négatives + +* Un nouveau type public (`AnyStringOneOf`) et une méthode à maintenir et documenter, et une seconde forme de `OneOf` + dans la bibliothèque — terminale pour les chaînes, composable pour les scalaires — que la documentation doit + expliquer. +* La bibliothèque ne vérifie pas que les valeurs fournies respectent un format externe : c'est le contenu de + l'appelant, et un objet-valeur a toujours besoin de `As(...)` pour imposer son invariant. + +### Risques + +* Un appelant peut attendre la composabilité du `OneOf` scalaire et être surpris que celui des chaînes soit terminal. + Atténué par le type renvoyé qui ne porte aucune méthode de mise en forme et par le conflit à la déclaration lors + qu'une contrainte le précède — les deux rendent la nature terminale explicite au point d'appel. + +## Actions de suivi + +* Documenter le générateur dans le README du paquet `Dummies` (fait) et dans la documentation utilisateur lors de la + prochaine révision de la surface des chaînes. +* Garder exact le non-objectif « pas de fausses données réalistes » du README : `OneOf` tire dans des valeurs + fournies par l'appelant et ne livre aucun jeu de données. + +## Références + +* ADR-0025 — Générer les chaînes qui matchent depuis un sous-ensemble régulier maison (le précédent du générateur + terminal). +* ADR-0013 — Borner les collections distinctes par la cardinalité, sinon par un tirage borné (le contrat + `ICardinalityHint`). +* ADR-0006 — Fournir des valeurs de test arbitraires depuis une source unique seedable (la reproductibilité). +* ADR-0011 — Héberger Dummies comme un paquet autonome dans ce dépôt (la frontière zéro-dépendance, sans jeu de + données). +* Le type `AnyStringOneOf`, la méthode `AnyString.OneOf` et leurs tests dans le projet `Dummies` et + `Dummies.UnitTests`. diff --git a/doc/handwritten/for-maintainers/adr/0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md b/doc/handwritten/for-maintainers/adr/0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md new file mode 100644 index 0000000..10a4c45 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md @@ -0,0 +1,129 @@ +# ADR-0030 | Draw arbitrary strings from an explicit, terminal value set + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.fr.md) + +**Status:** Accepted +**Date:** 2026-07-21 +**Decision Makers:** Reefact + +## Context + +`Dummies` supplies arbitrary yet valid values, with constraints that express what the surrounding code requires of +a value. A recurring need is a value whose domain is a **fixed, closed list** the test does not assert on — a +currency code drawn from a short table, a status label, a well-known company name. The library generates +**structural** shapes (a length, a character family, a regular pattern); it cannot synthesize such a real-world set, +and the caller holds the values. + +Several existing facts frame the choice: + +* The scalar and enum generators already expose `OneOf(params T[])` — draw uniformly from an explicit allow-list — + but there it is a **composable** constraint: it narrows *within* the type's interval or pool and cross-validates + against the other constraints. `AnyString` has no `OneOf` at all. +* `Any.StringMatching` (ADR-0025) is a **terminal** generator: the pattern is the whole specification, so it exposes + no further shape or length constraints, yet it still composes through `As`, `OrNull`, `Combine` and the collection + generators as any `IAny` does. +* A string's shaping surface is far wider than a scalar's interval: prefix, suffix, contained fragments, character + family, letter casing and length, each already cross-validated against the others. +* Distinct collections gate on an element generator's advertised cardinality at declaration time (ADR-0013), through + the internal `ICardinalityHint`; a generator that does not advertise one falls back to a bounded dedup draw. +* The library draws from a single seedable source so any run is reproducible (ADR-0006), builds values to satisfy + the constraints rather than generate-and-filter, and ships with **zero runtime dependencies and no datasets** — + its README lists "no realistic fake data (names, emails, addresses)" as an explicit non-goal (ADR-0011). +* The requested call shape is `Any.String().OneOf(...)` — chained off the string entry point. + +## Decision + +`Any.String().OneOf(...)` draws the string from an explicit set of caller-supplied values as a **terminal** +generator — the set is the whole specification and does not combine with the other string constraints — rather than +as a composable constraint like the scalar generators' `OneOf`. + +## Rationale + +* **A terminal set keeps the surface small and contradiction-free.** Reconciling an explicit value set with a + string's prefix, suffix, fragments, character family, casing and length would multiply contradictory combinations + and their conflict messages, for a combination nobody needs — a caller who supplies literal values already fixes + their shape. Making the set the whole specification removes that whole class at once. `Any.StringMatching` reached + the same conclusion for the same reason (ADR-0025); matching that precedent keeps the two string terminals + coherent. +* **It stays on `Any.String()` for discoverability, and stays honest through fail-fast.** A caller reaches for + `Any.String()` and finds `OneOf` beside the other ways to obtain a string. The terminal nature is enforced two + ways: the returned generator carries no shaping methods, and declaring `OneOf` after another constraint raises a + `ConflictingAnyConstraintException` at declaration time — the same "an impossible Arrange is a test defect" rule + the library applies to every other conflict. +* **Caller-supplied values preserve the library's identity.** The realistic content lives in the consumer's test, + not in the package, so the "no realistic fake data" non-goal holds and no dataset, dependency, or network call is + introduced. `OneOf` is the dependency-free, deterministic answer to "give me a plausible value from a known set". +* **Advertising cardinality keeps distinct collections eager.** An explicit set is a small countable domain, so the + generator implements `ICardinalityHint`; a distinct collection over it gates eagerly (ADR-0013), exactly + as it does over `AnyChar` or `AnyEnum`, instead of silently relying on the bounded dedup-draw fallback. +* **Reproducibility is preserved.** The value is a uniform pick from the deduplicated set through the same seedable + source as every other generator, so a run replays under a seed (ADR-0006); collapsing duplicates keeps a listed + value from being implicitly weighted. + +## Alternatives Considered + +### A composable `OneOf` on `AnyString`, like the scalar generators + +Considered for surface symmetry with `AnyInt32.OneOf` and its peers. Rejected because a string's shaping constraints +intersect an explicit value set in many ways, each needing its own eager conflict analysis and message, for a +combination a caller supplying literals never needs — the terminal form removes the whole class, consistent with +ADR-0025. + +### A static factory `Any.StringOneOf(...)` (or `Any.OneOf(...)`), parallel to `Any.StringMatching` + +Considered because a static factory is terminal from the first call and sidesteps any "a constraint is already +declared" case. Rejected because the requested and more discoverable surface is `Any.String().OneOf(...)`, which +keeps the string entry points together; the prior-constraint case is covered by a clear declaration-time conflict, +the mechanism the library already uses for every impossible combination. + +### Ship curated realistic datasets (`Any.CompanyName()`, `Any.FirstName()`, ...) + +Considered because it answers "give me a plausible value" directly. Rejected because it contradicts the stated +non-goal of shipping no realistic fake data, and would make the library own, grow and localize an open-ended dataset; +the consumer supplies the set and `OneOf` draws from it instead. + +### Generate the set on first run through an external service and cache it + +Considered as a way to author the set without hand-writing it. Rejected because it would add a runtime dependency and +a non-deterministic, non-hermetic first run to a library whose identity is zero-dependency, deterministic generation +(ADR-0006, ADR-0011); authoring the set is a design-time concern that belongs outside the library. + +## Consequences + +### Positive + +* A value whose domain is a short, closed list becomes a one-line, dependency-free, reproducible dummy that composes + into value objects (`As`), optionals (`OrNull`) and collections like every other generator. +* The terminal shape keeps the string surface small and free of a new class of contradictory constraint + combinations. +* A distinct collection over the set is gated eagerly by its cardinality, consistent with the other countable-domain + generators. + +### Negative + +* A new public type (`AnyStringOneOf`) and method to maintain and document, and a second `OneOf` shape in the library + — terminal for strings, composable for scalars — that the documentation must explain. +* The library does not check that the supplied values meet any external format: they are the caller's content, and a + value object still needs `As(...)` to enforce its invariant. + +### Risks + +* A caller may expect the scalar `OneOf`'s composability and be surprised the string one is terminal. Mitigated by + the returned type carrying no shaping methods and by the declaration-time conflict when a constraint precedes it — + both make the terminal nature explicit at the call site. + +## Follow-up Actions + +* Document the generator in the `Dummies` package README (done) and in the user documentation when the string surface + is next revised. +* Keep the "no realistic fake data" non-goal in the README accurate: `OneOf` draws from caller-supplied values and + ships no dataset. + +## References + +* ADR-0025 — Generate matching strings from a home-grown regular subset (the terminal-generator precedent). +* ADR-0013 — Gate distinct collections by cardinality, otherwise by a bounded draw (the `ICardinalityHint` contract). +* ADR-0006 — Supply arbitrary test values from a single seedable source (reproducibility). +* ADR-0011 — Host Dummies as a standalone package in this repository (the zero-dependency, no-dataset boundary). +* The `AnyStringOneOf` type, the `AnyString.OneOf` method, and their tests in the `Dummies` project and + `Dummies.UnitTests`. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 764ef11..e003c6c 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -212,3 +212,4 @@ Optional supporting material: | [ADR-0027](0027-repair-dependabot-pull-requests-within-a-risk-boundary.md) | Repair Dependabot pull requests within a risk boundary | Accepted | | [ADR-0028](0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.md) | Bridge throwing code into outcomes through a guarded Try | Accepted | | [ADR-0029](0029-complete-the-outcome-try-async-surface-with-token-less-overloads.md) | Complete the Outcome.Try async surface with token-less overloads | Accepted | +| [ADR-0030](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md) | Draw arbitrary strings from an explicit, terminal value set | Accepted |