From 6e0f413f5ae3f0014682e3476c14ac744bb01863 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:26:00 +0000 Subject: [PATCH 1/3] feat(dummies): add WithChars custom alphabet to AnyString MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnyString could only draw its filler from the built-in Alpha/Numeric/ AlphaNumeric sets, so non-ASCII text (accents, other scripts) was reachable only through a StringMatching literal. Add WithChars(string pool), the general form of the named character sets: the generated string is drawn from the caller's explicit pool. WithChars occupies the same character-family slot as the named sets, so it is declared once and conflicts with Alpha/Numeric/AlphaNumeric and with a second WithChars, naming both sides. Because the pool is the whole character definition it also conflicts with LowerCase/UpperCase — put only the casing you want in the pool. Anchored fragments (prefix, suffix, contained value) are validated against the pool eagerly, exactly like the named sets, so an out-of-pool character surfaces at declaration, not at generation. Duplicate characters collapse and each distinct character is drawn uniformly. Because Dummies draws the character itself from the pool — no CultureInfo or Uri/IdnMapping involved — a seeded WithChars draw stays reproducible across target frameworks. The pool is a sequence of UTF-16 code units: a code point outside the Basic Multilingual Plane is two units and is not guaranteed to be drawn as an indivisible unit (a first-class Rune builder remains future work). Declare the method in both per-TFM public-API baselines, extend the surface-parity algebra for AnyString, and cover behaviour, reachability, i18n, both conflict orders, dedup, argument validation and seeded reproducibility. Refs: #226 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FPAdCRsiaVo2orjJwCaDnb --- Dummies.UnitTests/AnyStringTests.cs | 118 ++++++++++++++++++ Dummies.UnitTests/SurfaceParityTests.cs | 2 +- Dummies/AnyString.cs | 26 ++++ .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 1 + .../netstandard2.0/PublicAPI.Unshipped.txt | 1 + Dummies/README.nuget.md | 5 + Dummies/StringSpec.cs | 70 ++++++++--- 7 files changed, 203 insertions(+), 20 deletions(-) diff --git a/Dummies.UnitTests/AnyStringTests.cs b/Dummies.UnitTests/AnyStringTests.cs index e16b7184..024c69a3 100644 --- a/Dummies.UnitTests/AnyStringTests.cs +++ b/Dummies.UnitTests/AnyStringTests.cs @@ -320,4 +320,122 @@ public void ExclusionArgumentsAreValidated() { Check.ThatCode(() => Any.String().Except("a", null!)).Throws(); } + [Fact(DisplayName = "WithChars draws every character from the supplied pool.")] + public void WithCharsDrawsFromThePool() { + const string pool = "0123456789ABCDEF"; + foreach (string value in Samples(Any.String().WithChars(pool).NonEmpty())) { + Check.That(value.All(character => pool.IndexOf(character) >= 0)).IsTrue(); + } + } + + [Fact(DisplayName = "WithChars reaches every character in the pool.")] + public void WithCharsReachesEveryCharacter() { + const string pool = "ACGT"; + HashSet seen = new(); + foreach (string value in Samples(Any.String().WithChars(pool).WithLength(8))) { + foreach (char character in value) { seen.Add(character); } + } + + Check.That(pool.All(character => seen.Contains(character))).IsTrue(); + } + + [Fact(DisplayName = "WithChars reaches non-ASCII characters a named charset cannot.")] + public void WithCharsReachesNonAscii() { + const string pool = "àâäéèêëîïôùûüç"; + foreach (string value in Samples(Any.String().WithChars(pool).NonEmpty())) { + Check.That(value.All(character => pool.IndexOf(character) >= 0)).IsTrue(); + } + } + + [Fact(DisplayName = "WithChars honours an exact length.")] + public void WithCharsHonoursExactLength() { + foreach (string value in Samples(Any.String().WithChars("xyz").WithLength(7))) { + Check.That(value.Length).IsEqualTo(7); + } + } + + [Fact(DisplayName = "WithChars collapses duplicate characters in the pool.")] + public void WithCharsCollapsesDuplicates() { + foreach (string value in Samples(Any.String().WithChars("aaabbb").WithLength(4))) { + Check.That(value.All(character => character is 'a' or 'b')).IsTrue(); + } + } + + [Fact(DisplayName = "WithChars combines with an exclusion over its own pool.")] + public void WithCharsCombinesWithExclusion() { + foreach (string value in Samples(Any.String().WithChars("ab").WithLength(1).DifferentFrom("a"))) { + Check.That(value).IsEqualTo("b"); + } + } + + [Fact(DisplayName = "A seeded WithChars draw is reproducible: the same seed yields the same value.")] + public void SeededWithCharsIsReproducible() { + string first = Any.WithSeed(4242).String().WithChars("αβγδεζ").WithLength(5).Generate(); + string second = Any.WithSeed(4242).String().WithChars("αβγδεζ").WithLength(5).Generate(); + + Check.That(second).IsEqualTo(first); + } + + [Fact(DisplayName = "WithChars then a named charset conflicts: one character family per generator.")] + public void WithCharsThenNamedCharsetConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().WithChars("abc").Numeric()); + + Check.That(conflict.Message).Contains("Numeric()"); + Check.That(conflict.Message).Contains("WithChars(\"abc\")"); + } + + [Fact(DisplayName = "A named charset then WithChars conflicts: order does not matter.")] + public void NamedCharsetThenWithCharsConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().Alpha().WithChars("абвгд")); + + Check.That(conflict.Message).Contains("WithChars(\"абвгд\")"); + Check.That(conflict.Message).Contains("Alpha()"); + } + + [Fact(DisplayName = "WithChars then a casing conflicts: the pool is the whole character definition.")] + public void WithCharsThenCasingConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().WithChars("abc").LowerCase()); + + Check.That(conflict.Message).Contains("LowerCase()"); + Check.That(conflict.Message).Contains("WithChars(\"abc\")"); + } + + [Fact(DisplayName = "A casing then WithChars conflicts: order does not matter.")] + public void CasingThenWithCharsConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().UpperCase().WithChars("abc")); + + Check.That(conflict.Message).Contains("WithChars(\"abc\")"); + Check.That(conflict.Message).Contains("UpperCase()"); + } + + [Fact(DisplayName = "A WithChars pool cannot anchor a prefix with an outside character.")] + public void WithCharsPrefixOutsidePoolConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().WithChars("0123456789").StartingWith("ID-")); + + Check.That(conflict.Message).Contains("StartingWith(\"ID-\")"); + Check.That(conflict.Message).Contains("WithChars(\"0123456789\")"); + Check.That(conflict.Message).Contains("'I'"); + } + + [Fact(DisplayName = "Declaring WithChars after an incompatible fragment conflicts too: order does not matter.")] + public void WithCharsAfterIncompatibleFragmentConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().StartingWith("ID-").WithChars("0123456789")); + + Check.That(conflict.Message).Contains("WithChars(\"0123456789\")"); + Check.That(conflict.Message).Contains("ID-"); + Check.That(conflict.Message).Contains("'I'"); + } + + [Fact(DisplayName = "WithChars arguments are validated as arguments, not as conflicts.")] + public void WithCharsArgumentsAreValidated() { + Check.ThatCode(() => Any.String().WithChars(null!)).Throws(); + Check.ThatCode(() => Any.String().WithChars("")).Throws(); + } + } diff --git a/Dummies.UnitTests/SurfaceParityTests.cs b/Dummies.UnitTests/SurfaceParityTests.cs index f36233b5..ee899113 100644 --- a/Dummies.UnitTests/SurfaceParityTests.cs +++ b/Dummies.UnitTests/SurfaceParityTests.cs @@ -137,7 +137,7 @@ public static IEnumerable Builders() { // self-returning constraint and does not appear in this fluent-method set. yield return [typeof(AnyString), new[] { "NonEmpty", "WithLength", "WithMinLength", "WithMaxLength", "WithLengthBetween", - "StartingWith", "EndingWith", "Containing", "Alpha", "AlphaNumeric", "Numeric", "UpperCase", "LowerCase", + "StartingWith", "EndingWith", "Containing", "Alpha", "AlphaNumeric", "Numeric", "WithChars", "UpperCase", "LowerCase", "Except", "DifferentFrom" }]; diff --git a/Dummies/AnyString.cs b/Dummies/AnyString.cs index 547f4c8d..3495f9f4 100644 --- a/Dummies/AnyString.cs +++ b/Dummies/AnyString.cs @@ -192,6 +192,32 @@ public AnyString AlphaNumeric() { return new AnyString(_source, _spec.WithCharset(CharacterSet.AlphaNumeric, "AlphaNumeric()")); } + /// + /// Restricts the string to the characters of an explicit — a custom alphabet, the + /// general form of //. Use it to reach + /// characters the named sets cannot, most notably non-ASCII text (accents, other scripts), without a + /// literal. Declared once per generator: it occupies the same + /// character-family slot as the named sets, and because the pool is the whole character definition it cannot + /// combine with / — put only the casing you want in the pool. + /// Any anchored fragment (prefix, suffix, contained value) must be drawn from the pool, otherwise the conflict + /// is reported at declaration naming both sides. Duplicate characters collapse and each distinct character is + /// equally likely. The pool is a sequence of UTF-16 code units, so a code point outside the Basic Multilingual + /// Plane (an astral emoji) is two units and is not guaranteed to be drawn as an indivisible unit. + /// + /// The characters the generated string is drawn from; duplicates are ignored. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when is empty. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString WithChars(string pool) { + if (pool is null) { throw new ArgumentNullException(nameof(pool)); } + if (pool.Length == 0) { throw new ArgumentException("The character pool must not be empty.", nameof(pool)); } + + string distinct = new(pool.Distinct().ToArray()); + + return new AnyString(_source, _spec.WithCharPool(distinct, $"WithChars(\"{pool}\")")); + } + /// Requires every alphabetic character to be lowercase. Declared once per generator. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 2d64bbdb..bc3df9ae 100644 --- a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -281,6 +281,7 @@ Dummies.AnyString.OneOf(params string![]! values) -> Dummies.AnyStringOneOf! Dummies.AnyString.OneOf(System.Collections.Generic.IEnumerable! values) -> Dummies.AnyStringOneOf! Dummies.AnyString.StartingWith(string! prefix) -> Dummies.AnyString! Dummies.AnyString.UpperCase() -> Dummies.AnyString! +Dummies.AnyString.WithChars(string! pool) -> Dummies.AnyString! Dummies.AnyString.WithLength(int length) -> Dummies.AnyString! Dummies.AnyString.WithLengthBetween(int minimum, int maximum) -> Dummies.AnyString! Dummies.AnyString.WithMaxLength(int length) -> Dummies.AnyString! diff --git a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 9f341225..8e70fbd6 100644 --- a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -238,6 +238,7 @@ Dummies.AnyString.OneOf(params string![]! values) -> Dummies.AnyStringOneOf! Dummies.AnyString.OneOf(System.Collections.Generic.IEnumerable! values) -> Dummies.AnyStringOneOf! Dummies.AnyString.StartingWith(string! prefix) -> Dummies.AnyString! Dummies.AnyString.UpperCase() -> Dummies.AnyString! +Dummies.AnyString.WithChars(string! pool) -> Dummies.AnyString! Dummies.AnyString.WithLength(int length) -> Dummies.AnyString! Dummies.AnyString.WithLengthBetween(int minimum, int maximum) -> Dummies.AnyString! Dummies.AnyString.WithMaxLength(int length) -> Dummies.AnyString! diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 9dbc2ecc..a1f95299 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -37,6 +37,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. +- **Custom alphabets**: `Any.String().WithChars("αβγδε")` restricts the string to an + explicit character pool — the general form of the built-in `Alpha`/`Numeric`/ + `AlphaNumeric` sets, and the way to reach non-ASCII text (accents, other scripts) + without a `StringMatching` literal. Anchored fragments must be drawn from the pool, or + the conflict is reported at declaration. - **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 diff --git a/Dummies/StringSpec.cs b/Dummies/StringSpec.cs index 5ff9ffb5..fcdae4c6 100644 --- a/Dummies/StringSpec.cs +++ b/Dummies/StringSpec.cs @@ -43,7 +43,7 @@ internal sealed class StringSpec { internal static readonly StringSpec Unconstrained = new(null, null, 0, null, null, null, null, null, null, null, [], - null, null, null, null, []); + null, null, null, null, null, []); private static string V(int value) { return value.ToString(CultureInfo.InvariantCulture); @@ -61,6 +61,7 @@ private static string Characters(int count) { private readonly string? _casingConstraint; private readonly CharacterSet? _charset; private readonly string? _charsetConstraint; + private readonly string? _customPool; private readonly int? _exactLength; private readonly string? _exactConstraint; private readonly IReadOnlyList _excluded; @@ -82,7 +83,7 @@ private StringSpec(int? exactLength, string? exactConstraint, string? prefix, string? prefixConstraint, string? suffix, string? suffixConstraint, IReadOnlyList fragments, - CharacterSet? charset, string? charsetConstraint, + CharacterSet? charset, string? charsetConstraint, string? customPool, LetterCasing? casing, string? casingConstraint, IReadOnlyList excluded) { _exactLength = exactLength; @@ -99,6 +100,7 @@ private StringSpec(int? exactLength, string? exactConstraint, _fragments = fragments; _charset = charset; _charsetConstraint = charsetConstraint; + _customPool = customPool; _casing = casing; _casingConstraint = casingConstraint; } @@ -111,7 +113,7 @@ private StringSpec(int? exactLength, string? exactConstraint, 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 && _excluded.Count == 0; + _charset is null && _casing is null && _excluded.Count == 0 && _customPool is null; /// Fixes the exact length; declared once per generator. internal StringSpec WithExactLength(int length, string applying) { @@ -119,7 +121,7 @@ internal StringSpec WithExactLength(int length, string applying) { StringSpec candidate = new(length, applying, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); + _charset, _charsetConstraint, _customPool, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -130,7 +132,7 @@ internal StringSpec WithMinLength(int length, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, length, applying, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); + _charset, _charsetConstraint, _customPool, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -141,7 +143,7 @@ internal StringSpec WithMaxLength(int length, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, length, applying, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); + _charset, _charsetConstraint, _customPool, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -152,7 +154,7 @@ internal StringSpec WithPrefix(string prefix, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, prefix, applying, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); + _charset, _charsetConstraint, _customPool, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -163,7 +165,7 @@ internal StringSpec WithSuffix(string suffix, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, suffix, applying, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); + _charset, _charsetConstraint, _customPool, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -174,7 +176,7 @@ internal StringSpec WithFragment(string fragment, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, fragments, - _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); + _charset, _charsetConstraint, _customPool, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -185,7 +187,24 @@ internal StringSpec WithCharset(CharacterSet charset, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - charset, applying, _casing, _casingConstraint, _excluded); + charset, applying, _customPool, _casing, _casingConstraint, _excluded); + + return candidate.Validated(applying); + } + + /// + /// Restricts the filler to an explicit character pool — the general form of the named character sets. + /// Occupies the charset slot (declared once, and mutually exclusive with the named sets) and, because the + /// pool is the whole character definition, cannot combine with a casing. The pool is expected to be + /// distinct already. + /// + internal StringSpec WithCharPool(string pool, string applying) { + if (_charsetConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_charsetConstraint} is already defined."); } + if (_casingConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_casingConstraint} is already defined."); } + + StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, + _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, + _charset, applying, pool, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -193,10 +212,11 @@ internal StringSpec WithCharset(CharacterSet charset, string applying) { /// Imposes a letter casing; declared once per generator. internal StringSpec WithCasing(LetterCasing casing, string applying) { if (_casingConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_casingConstraint} is already defined."); } + if (_customPool is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_charsetConstraint} is already defined."); } StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, casing, applying, _excluded); + _charset, _charsetConstraint, _customPool, casing, applying, _excluded); return candidate.Validated(applying); } @@ -210,7 +230,7 @@ internal StringSpec WithExcluded(IReadOnlyList values, string applying) StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint, excluded); + _charset, _charsetConstraint, _customPool, _casing, _casingConstraint, excluded); return candidate.Validated(applying); } @@ -328,13 +348,11 @@ private void ValidateFragmentBudget(string applying) { private void ValidateFragmentCharacters(string applying) { foreach ((string kind, string fragment) in Fragments()) { - if (_charset is CharacterSet charset) { - char? offending = FirstOutsideCharset(fragment, charset); - if (offending is char outside) { - throw new ConflictingAnyConstraintException(applying == _charsetConstraint - ? $"Cannot apply {applying} because the {kind} \"{fragment}\" contains '{outside}', which it does not allow." - : $"Cannot apply {applying} because {_charsetConstraint} does not allow its character '{outside}'."); - } + char? offendingCharacter = FirstDisallowedCharacter(fragment); + if (offendingCharacter is char outside) { + throw new ConflictingAnyConstraintException(applying == _charsetConstraint + ? $"Cannot apply {applying} because the {kind} \"{fragment}\" contains '{outside}', which it does not allow." + : $"Cannot apply {applying} because {_charsetConstraint} does not allow its character '{outside}'."); } if (_casing is LetterCasing casing) { @@ -371,6 +389,18 @@ private int RequiredLength() { return required; } + private char? FirstDisallowedCharacter(string fragment) { + if (_customPool is not null) { + foreach (char character in fragment) { + if (_customPool.IndexOf(character) < 0) { return character; } + } + + return null; + } + + return _charset is CharacterSet charset ? FirstOutsideCharset(fragment, charset) : null; + } + private static char? FirstOutsideCharset(string fragment, CharacterSet charset) { foreach (char character in fragment) { bool allowed = charset switch { @@ -395,6 +425,8 @@ private int RequiredLength() { } private string FillerPool() { + if (_customPool is not null) { return _customPool; } + string letters = _casing switch { LetterCasing.Lower => CharacterPools.LowerLetters, LetterCasing.Upper => CharacterPools.UpperLetters, From 93cac7bf4e78f771ea882b54ce4360dac72b437e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 06:43:55 +0000 Subject: [PATCH 2/3] fix(dummies): reject astral pools in WithChars, point to OneOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WithChars draws UTF-16 code units independently, so a pool holding a surrogate (an emoji or other astral code point) could be drawn — and split — one unit at a time, yielding a broken surrogate. That silently breaks the library's arbitrary-yet-valid contract. Reject a surrogate-bearing pool eagerly with an ArgumentException whose message names the fix: draw such values as whole strings with OneOf(...). This keeps WithChars valid by construction and leaves astral and grapheme needs to the existing string-set surface (and a future net8-only Rune builder). BMP pools — accents, Greek, Cyrillic, CJK — are unaffected. Refs: #226 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FPAdCRsiaVo2orjJwCaDnb --- Dummies.UnitTests/AnyStringTests.cs | 7 +++++++ Dummies/AnyString.cs | 9 ++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Dummies.UnitTests/AnyStringTests.cs b/Dummies.UnitTests/AnyStringTests.cs index 024c69a3..59ad6d0d 100644 --- a/Dummies.UnitTests/AnyStringTests.cs +++ b/Dummies.UnitTests/AnyStringTests.cs @@ -438,4 +438,11 @@ public void WithCharsArgumentsAreValidated() { Check.ThatCode(() => Any.String().WithChars("")).Throws(); } + [Fact(DisplayName = "WithChars rejects a pool with an astral code point and points to OneOf.")] + public void WithCharsRejectsAstralPool() { + ArgumentException error = Assert.Throws(() => Any.String().WithChars("😀🎉")); + + Check.That(error.Message).Contains("OneOf"); + } + } diff --git a/Dummies/AnyString.cs b/Dummies/AnyString.cs index 3495f9f4..b0184e8e 100644 --- a/Dummies/AnyString.cs +++ b/Dummies/AnyString.cs @@ -201,17 +201,20 @@ public AnyString AlphaNumeric() { /// combine with / — put only the casing you want in the pool. /// Any anchored fragment (prefix, suffix, contained value) must be drawn from the pool, otherwise the conflict /// is reported at declaration naming both sides. Duplicate characters collapse and each distinct character is - /// equally likely. The pool is a sequence of UTF-16 code units, so a code point outside the Basic Multilingual - /// Plane (an astral emoji) is two units and is not guaranteed to be drawn as an indivisible unit. + /// equally likely. The pool is a sequence of UTF-16 code units and must stay within the Basic Multilingual + /// Plane: a surrogate — an emoji or other astral code point, which spans two units — is rejected, because it + /// would be drawn and split unit by unit; draw such values as whole strings with + /// instead. /// /// The characters the generated string is drawn from; duplicates are ignored. /// A new generator carrying the added constraint. /// Thrown when is null. - /// Thrown when is empty. + /// Thrown when is empty or contains a surrogate (an astral code point). /// Thrown when the constraint contradicts a constraint already declared. public AnyString WithChars(string pool) { if (pool is null) { throw new ArgumentNullException(nameof(pool)); } if (pool.Length == 0) { throw new ArgumentException("The character pool must not be empty.", nameof(pool)); } + if (pool.Any(char.IsSurrogate)) { throw new ArgumentException("The character pool must not contain a surrogate: an emoji or other astral code point spans two UTF-16 code units, which WithChars would draw and split independently. Draw such values as whole strings with OneOf(...) instead.", nameof(pool)); } string distinct = new(pool.Distinct().ToArray()); From 1be2607ac949b929d762a58f4ae4343dbf03b4c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 06:58:21 +0000 Subject: [PATCH 3/3] docs(dummies): document the WithChars BMP boundary and OneOf escape Refine the package readme's Custom alphabets entry so it teaches the one non-obvious point: WithChars stays within the Basic Multilingual Plane and rejects a surrogate, so an emoji or other astral character is drawn as a whole string with OneOf(...) rather than as a character family. Refs: #226 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FPAdCRsiaVo2orjJwCaDnb --- Dummies/README.nuget.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index a1f95299..5592727e 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -37,11 +37,14 @@ 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. -- **Custom alphabets**: `Any.String().WithChars("αβγδε")` restricts the string to an +- **Custom alphabets**: `Any.String().WithChars("αβγδε")` draws the string from an explicit character pool — the general form of the built-in `Alpha`/`Numeric`/ - `AlphaNumeric` sets, and the way to reach non-ASCII text (accents, other scripts) - without a `StringMatching` literal. Anchored fragments must be drawn from the pool, or - the conflict is reported at declaration. + `AlphaNumeric` sets, and the way to reach non-ASCII text (accents, Greek, Cyrillic, + CJK) without a `StringMatching` literal. It stays within the Basic Multilingual Plane + and rejects a surrogate: an emoji or other astral character is an atomic grapheme, not + a character family, so draw those as whole strings with `OneOf("😀", "🎉")` instead. + Anchored fragments must be drawn from the pool, or the conflict is reported at + declaration. - **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