From eac7c1272c96a9a49089533808d29d98e8a06171 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 17:14:49 +0000 Subject: [PATCH 1/3] feat(dummies): add terminal Any.String().OneOf for explicit value sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any.String() had no way to draw from a fixed, closed list of values — the dummy for a domain that is a short enumeration (a currency code, a well-known name) rather than a structural shape. The scalar generators offer a composable OneOf; strings now get a terminal one. OneOf(params string[]) returns AnyStringOneOf, a terminal IAny mirroring StringMatching (ADR-0025): the value set is the whole specification, so it exposes no shape, length or character constraints and rejects being declared after another constraint with a ConflictingAnyConstraintException. Values are deduplicated and drawn uniformly from the seedable source, so a run replays under a seed, and AnyStringOneOf advertises ICardinalityHint so a distinct collection over it is gated eagerly by its cardinality (ADR-0013). Adds unit tests, the package README entry, and ADR-0030 (Proposed, EN and FR). No new dependency: the zero-dependency architecture test and the whole solution build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011JxSJGauajxuFBot4NsHdn --- Dummies.UnitTests/AnyStringOneOfTests.cs | 135 +++++++++++++++++ Dummies/AnyString.cs | 22 +++ Dummies/AnyStringOneOf.cs | 53 +++++++ Dummies/README.nuget.md | 5 + Dummies/StringSpec.cs | 10 ++ ...trings-from-an-explicit-terminal-set.fr.md | 140 ++++++++++++++++++ ...y-strings-from-an-explicit-terminal-set.md | 129 ++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 8 files changed, 495 insertions(+) create mode 100644 Dummies.UnitTests/AnyStringOneOfTests.cs create mode 100644 Dummies/AnyStringOneOf.cs create mode 100644 doc/handwritten/for-maintainers/adr/0030-draw-arbitrary-strings-from-an-explicit-terminal-set.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md diff --git a/Dummies.UnitTests/AnyStringOneOfTests.cs b/Dummies.UnitTests/AnyStringOneOfTests.cs new file mode 100644 index 00000000..76a05f2f --- /dev/null +++ b/Dummies.UnitTests/AnyStringOneOfTests.cs @@ -0,0 +1,135 @@ +#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(); + } + +} diff --git a/Dummies/AnyString.cs b/Dummies/AnyString.cs index 2a67357d..81bb333c 100644 --- a/Dummies/AnyString.cs +++ b/Dummies/AnyString.cs @@ -202,6 +202,28 @@ 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()); + } + /// public string Generate() { return _spec.Generate(_source.Current.Random); diff --git a/Dummies/AnyStringOneOf.cs b/Dummies/AnyStringOneOf.cs new file mode 100644 index 00000000..dae0cc38 --- /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 271b6080..de2048ca 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 753611bf..1a201c15 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 00000000..3756f938 --- /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 :** Proposé +**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 00000000..1b258bf4 --- /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:** Proposed +**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 764ef113..cc17f180 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 | Proposed | From 3c0a769f54161e1a38dcf24272b05a9350dd854d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 18:59:11 +0000 Subject: [PATCH 2/3] feat(dummies): accept an IEnumerable of values in Any.String().OneOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal Any.String().OneOf so far took only params string[]. A set held as a sequence — a List, a LINQ result, values loaded at test setup — had to be materialized with ToArray at the call site. Add an IEnumerable overload that delegates to the params one, so all validation, deduplication and the terminal contract stay in a single place. Overload resolution is unambiguous: a string[] argument still binds to the more specific params overload (including OneOf(null!)), so no existing call changes. Covered by tests for the sequence path, its argument validation, and its terminal conflict. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011JxSJGauajxuFBot4NsHdn --- Dummies.UnitTests/AnyStringOneOfTests.cs | 22 ++++++++++++++++++++++ Dummies/AnyString.cs | 17 +++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/Dummies.UnitTests/AnyStringOneOfTests.cs b/Dummies.UnitTests/AnyStringOneOfTests.cs index 76a05f2f..e2d3aa37 100644 --- a/Dummies.UnitTests/AnyStringOneOfTests.cs +++ b/Dummies.UnitTests/AnyStringOneOfTests.cs @@ -132,4 +132,26 @@ public void RejectsInvalidValueLists() { 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 81bb333c..9c90762b 100644 --- a/Dummies/AnyString.cs +++ b/Dummies/AnyString.cs @@ -224,6 +224,23 @@ public AnyStringOneOf OneOf(params string[] values) { 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); From f8d592db85a63189ed3b92eda245f36340892d36 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 18:59:11 +0000 Subject: [PATCH 3/3] docs(dummies): accept ADR-0030 for the terminal string OneOf The maintainer accepted the decision recorded in ADR-0030 (draw arbitrary strings from an explicit, terminal value set). Flip its status from Proposed to Accepted in the English and French records and in the index. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011JxSJGauajxuFBot4NsHdn --- ...0-draw-arbitrary-strings-from-an-explicit-terminal-set.fr.md | 2 +- ...0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md | 2 +- doc/handwritten/for-maintainers/adr/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 index 3756f938..c4807827 100644 --- 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 @@ -2,7 +2,7 @@ 🌍 🇬🇧 [English](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md) · 🇫🇷 Français (ce fichier) -**Statut :** Proposé +**Statut :** Accepté **Date :** 2026-07-21 **Décideurs :** Reefact 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 index 1b258bf4..10a4c45b 100644 --- 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 @@ -2,7 +2,7 @@ 🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.fr.md) -**Status:** Proposed +**Status:** Accepted **Date:** 2026-07-21 **Decision Makers:** Reefact diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index cc17f180..e003c6cf 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -212,4 +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 | Proposed | +| [ADR-0030](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md) | Draw arbitrary strings from an explicit, terminal value set | Accepted |