Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions Dummies.UnitTests/AnyStringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,4 +320,129 @@ public void ExclusionArgumentsAreValidated() {
Check.ThatCode(() => Any.String().Except("a", null!)).Throws<ArgumentException>();
}

[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<char> 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<ConflictingAnyConstraintException>(
() => 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<ConflictingAnyConstraintException>(
() => 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<ConflictingAnyConstraintException>(
() => 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<ConflictingAnyConstraintException>(
() => 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<ConflictingAnyConstraintException>(
() => 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<ConflictingAnyConstraintException>(
() => 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<ArgumentNullException>();
Check.ThatCode(() => Any.String().WithChars("")).Throws<ArgumentException>();
}

[Fact(DisplayName = "WithChars rejects a pool with an astral code point and points to OneOf.")]
public void WithCharsRejectsAstralPool() {
ArgumentException error = Assert.Throws<ArgumentException>(() => Any.String().WithChars("😀🎉"));

Check.That(error.Message).Contains("OneOf");
}

}
2 changes: 1 addition & 1 deletion Dummies.UnitTests/SurfaceParityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public static IEnumerable<object[]> 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"
}];

Expand Down
29 changes: 29 additions & 0 deletions Dummies/AnyString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,14 @@
return string.Join(", ", values.Select(value => $"\"{value}\""));
}

private static string RequireText(string value, string parameterName) {

Check warning on line 49 in Dummies/AnyString.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Change return type to 'void'; not a single caller uses the returned value.

Check warning on line 49 in Dummies/AnyString.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Change return type to 'void'; not a single caller uses the returned value.
if (value is null) { throw new ArgumentNullException(parameterName); }
if (value.Length == 0) { throw new ArgumentException("The value must not be empty.", parameterName); }

return value;
}

private static int RequireNonNegative(int length, string parameterName) {

Check warning on line 56 in Dummies/AnyString.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Change return type to 'void'; not a single caller uses the returned value.

Check warning on line 56 in Dummies/AnyString.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Change return type to 'void'; not a single caller uses the returned value.
if (length < 0) { throw new ArgumentOutOfRangeException(parameterName, length, "The length must not be negative."); }

return length;
Expand Down Expand Up @@ -192,6 +192,35 @@
return new AnyString(_source, _spec.WithCharset(CharacterSet.AlphaNumeric, "AlphaNumeric()"));
}

/// <summary>
/// Restricts the string to the characters of an explicit <paramref name="pool" /> — a custom alphabet, the
/// general form of <see cref="Alpha" />/<see cref="Numeric" />/<see cref="AlphaNumeric" />. Use it to reach
/// characters the named sets cannot, most notably non-ASCII text (accents, other scripts), without a
/// <see cref="Any.StringMatching(string)" /> 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 <see cref="LowerCase" />/<see cref="UpperCase" /> — 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 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 <see cref="OneOf(string[])" />
/// instead.
/// </summary>
/// <param name="pool">The characters the generated string is drawn from; duplicates are ignored.</param>
/// <returns>A new generator carrying the added constraint.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="pool" /> is <c>null</c>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="pool" /> is empty or contains a surrogate (an astral code point).</exception>
/// <exception cref="ConflictingAnyConstraintException">Thrown when the constraint contradicts a constraint already declared.</exception>
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());

return new AnyString(_source, _spec.WithCharPool(distinct, $"WithChars(\"{pool}\")"));
}

/// <summary>Requires every alphabetic character to be lowercase. Declared once per generator.</summary>
/// <returns>A new generator carrying the added constraint.</returns>
/// <exception cref="ConflictingAnyConstraintException">Thrown when the constraint contradicts a constraint already declared.</exception>
Expand Down
1 change: 1 addition & 0 deletions Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ Dummies.AnyString.OneOf(params string![]! values) -> Dummies.AnyStringOneOf!
Dummies.AnyString.OneOf(System.Collections.Generic.IEnumerable<string!>! 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!
Expand Down
1 change: 1 addition & 0 deletions Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ Dummies.AnyString.OneOf(params string![]! values) -> Dummies.AnyStringOneOf!
Dummies.AnyString.OneOf(System.Collections.Generic.IEnumerable<string!>! 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!
Expand Down
8 changes: 8 additions & 0 deletions Dummies/README.nuget.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +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("αβγδε")` 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, 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
Expand Down
Loading