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
63 changes: 63 additions & 0 deletions Dummies.UnitTests/AnyStringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,67 @@ public void FragmentArgumentsAreValidated() {
Check.ThatCode(() => Any.String().Containing("")).Throws<ArgumentException>();
}

[Fact(DisplayName = "DifferentFrom never returns the excluded value.")]
public void DifferentFromNeverReturnsTheExcludedValue() {
foreach (string value in Samples(Any.String().WithLength(1).Alpha().DifferentFrom("A"))) {
Check.That(value).IsNotEqualTo("A");
}
}

[Fact(DisplayName = "Except excludes each listed value.")]
public void ExceptExcludesEachListedValue() {
string[] forbidden = { "A", "B", "C" };
foreach (string value in Samples(Any.String().WithLength(1).Alpha().Except("A", "B", "C"))) {
Check.That(forbidden.Contains(value)).IsFalse();
}
}

[Fact(DisplayName = "An exclusion preserves the declared shape: only shape-matching survivors are drawn.")]
public void ExclusionPreservesTheDeclaredShape() {
foreach (string value in Samples(Any.String().StartingWith("ORD-").WithLength(5).DifferentFrom("ORD-A"))) {
Check.That(value).StartsWith("ORD-");
Check.That(value.Length).IsEqualTo(5);
Check.That(value).IsNotEqualTo("ORD-A");
}
}

[Fact(DisplayName = "Exclusions accumulate across several declarations.")]
public void ExclusionsAccumulateAcrossDeclarations() {
foreach (string value in Samples(Any.String().WithLength(1).Alpha().Except("A", "B").DifferentFrom("C"))) {
Check.That(value is "A" or "B" or "C").IsFalse();
}
}

[Fact(DisplayName = "An over-tight exclusion fails at generation with a bounded, seed-bearing AnyGenerationException.")]
public void OverTightExclusionThrowsSeedBearingGenerationException() {
string[] everyLetter = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".Select(letter => letter.ToString()).ToArray();

AnyGenerationException error = Assert.Throws<AnyGenerationException>(
() => Any.WithSeed(20260721).String().WithLength(1).Alpha().Except(everyLetter).Generate());

Check.That(error.Seed).IsEqualTo(20260721);
Check.That(error.Message).Contains("Any.WithSeed(20260721)");
}

[Fact(DisplayName = "A seeded exclusion is reproducible: the same seed yields the same value.")]
public void SeededExclusionIsReproducible() {
string first = Any.WithSeed(4242).String().NonEmpty().Alpha().DifferentFrom("Q").Generate();
string second = Any.WithSeed(4242).String().NonEmpty().Alpha().DifferentFrom("Q").Generate();

Check.That(second).IsEqualTo(first);
}

[Fact(DisplayName = "OneOf cannot combine with an exclusion: it stays terminal.")]
public void OneOfCannotCombineWithAnExclusion() {
Check.ThatCode(() => Any.String().DifferentFrom("x").OneOf("a", "b")).Throws<ConflictingAnyConstraintException>();
}

[Fact(DisplayName = "Exclusion arguments are validated as arguments, not as conflicts.")]
public void ExclusionArgumentsAreValidated() {
Check.ThatCode(() => Any.String().DifferentFrom(null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => Any.String().Except(null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => Any.String().Except()).Throws<ArgumentException>();
Check.ThatCode(() => Any.String().Except("a", null!)).Throws<ArgumentException>();
}

}
7 changes: 5 additions & 2 deletions Dummies.UnitTests/SurfaceParityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,13 @@ public static IEnumerable<object[]> Builders() {
yield return [typeof(AnyEnum<DayOfWeek>), new[] { "OneOf", "Except", "DifferentFrom" }];
yield return [typeof(AnyChar), new[] { "Alpha", "AlphaNumeric", "Numeric", "UpperCase", "LowerCase", "OneOf", "Except", "DifferentFrom" }];

// AnyString is deliberately the only scalar builder with no exclusion constraints (no OneOf/Except/DifferentFrom).
// AnyString carries the exclusion pair Except/DifferentFrom (met by a bounded redraw, since strings are not
// ordinal-mapped). Its OneOf is terminal — it returns AnyStringOneOf, a different type, so it is not a
// 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", "UpperCase", "LowerCase",
"Except", "DifferentFrom"
}];

#if NET8_0_OR_GREATER
Expand Down
39 changes: 38 additions & 1 deletion Dummies/AnyString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,18 @@
return value.ToString(CultureInfo.InvariantCulture);
}

private static string Join(string[] values) {
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 @@ -202,6 +206,39 @@
return new AnyString(_source, _spec.WithCasing(LetterCasing.Upper, "UpperCase()"));
}

/// <summary>
/// Requires the generated string to be none of the supplied <paramref name="values" />. May be declared several
/// times; the exclusions accumulate. Unlike the shape constraints an exclusion is met by a <b>bounded</b> redraw
/// of the constructed layout, so an exclusion tight enough to leave the shape unsatisfiable surfaces at
/// <see cref="Generate" /> as a seed-bearing <see cref="AnyGenerationException" />, never as a declaration-time
/// conflict. The empty string is a valid value to exclude; a <c>null</c> element is not.
/// </summary>
/// <param name="values">The values the generated string must differ from; duplicates are ignored.</param>
/// <returns>A new generator carrying the added constraint.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="values" /> is <c>null</c>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="values" /> is empty or contains a <c>null</c> element.</exception>
public AnyString Except(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.", nameof(values)); }

return new AnyString(_source, _spec.WithExcluded(values, $"Except({Join(values)})"));
}

/// <summary>
/// Requires the generated string to differ from <paramref name="value" /> — typically an existing value the test
/// already holds, to exercise an inequality path while preserving the declared shape. Semantically equivalent to
/// <see cref="Except(string[])" />; the name carries the intent at the call site.
/// </summary>
/// <param name="value">The value the generated string must differ from.</param>
/// <returns>A new generator carrying the added constraint.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="value" /> is <c>null</c>.</exception>
public AnyString DifferentFrom(string value) {
if (value is null) { throw new ArgumentNullException(nameof(value)); }

return new AnyString(_source, _spec.WithExcluded([value], $"DifferentFrom(\"{value}\")"));
}

/// <summary>
/// Draws the string from an explicit, fixed set of <paramref name="values" /> 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
Expand Down Expand Up @@ -243,7 +280,7 @@

/// <inheritdoc />
public string Generate() {
return _spec.Generate(_source.Current.Random);
return _spec.Generate(_source);
}

}
2 changes: 2 additions & 0 deletions Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ Dummies.AnyString
Dummies.AnyString.Alpha() -> Dummies.AnyString!
Dummies.AnyString.AlphaNumeric() -> Dummies.AnyString!
Dummies.AnyString.Containing(string! value) -> Dummies.AnyString!
Dummies.AnyString.DifferentFrom(string! value) -> Dummies.AnyString!
Dummies.AnyString.EndingWith(string! suffix) -> Dummies.AnyString!
Dummies.AnyString.Except(params string![]! values) -> Dummies.AnyString!
Dummies.AnyString.Generate() -> string!
Dummies.AnyString.LowerCase() -> Dummies.AnyString!
Dummies.AnyString.NonEmpty() -> Dummies.AnyString!
Expand Down
2 changes: 2 additions & 0 deletions Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,9 @@ Dummies.AnyString
Dummies.AnyString.Alpha() -> Dummies.AnyString!
Dummies.AnyString.AlphaNumeric() -> Dummies.AnyString!
Dummies.AnyString.Containing(string! value) -> Dummies.AnyString!
Dummies.AnyString.DifferentFrom(string! value) -> Dummies.AnyString!
Dummies.AnyString.EndingWith(string! suffix) -> Dummies.AnyString!
Dummies.AnyString.Except(params string![]! values) -> Dummies.AnyString!
Dummies.AnyString.Generate() -> string!
Dummies.AnyString.LowerCase() -> Dummies.AnyString!
Dummies.AnyString.NonEmpty() -> Dummies.AnyString!
Expand Down
8 changes: 6 additions & 2 deletions Dummies/README.nuget.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@ matter — and that is the point.
identities with `NonEmpty`/`DifferentFrom` — and deliberately no clock-relative
constraints: a reproducible test pins its reference instants explicitly.
- **Values built to satisfy the constraints** — a scalar is constructed directly,
never generated-then-filtered. Only a *distinct* collection ever redraws, and only
to skip a duplicate: a **bounded** deduplicating draw, never an unbounded retry loop.
never generated-then-filtered. The one exception is excluding values from a string
(`Any.String().DifferentFrom(...)`/`Except(...)`): a string has no ordinal mapping to
build the exclusion into, so it is met by a **bounded** redraw — the same escape a
*distinct* collection uses to skip a duplicate, never an unbounded retry loop. An
exclusion tight enough to leave the shape unsatisfiable surfaces at generation as a
seed-bearing `AnyGenerationException`.
- **Conflicting constraints fail fast** with a clear, actionable
`ConflictingAnyConstraintException` at the moment the conflicting constraint is
declared — for example `Any.String().WithLength(3).StartingWith("ORD-")`.
Expand Down
Loading