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
157 changes: 157 additions & 0 deletions Dummies.UnitTests/AnyStringOneOfTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#region Usings declarations

using JetBrains.Annotations;

using NFluent;

#endregion

namespace Dummies.UnitTests;

[TestSubject(typeof(AnyStringOneOf))]
public sealed class AnyStringOneOfTests {

private const int SampleCount = 200;

#region Statics members declarations

private static IEnumerable<string> Samples(IAny<string> 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<string> 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<string> 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<string> 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<OrderReference> 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<string?> generator = Any.WithSeed(20260721).String().OneOf("a", "b").OrNull();

List<string?> 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<ConflictingAnyConstraintException>();

// Within the domain it fills the set with the requested distinct values.
HashSet<string> 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<ConflictingAnyConstraintException>(
() => 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<ConflictingAnyConstraintException>();
Check.ThatCode(() => Any.String().StartingWith("ORD-").OneOf("ORD-12345678")).Throws<ConflictingAnyConstraintException>();
Check.ThatCode(() => Any.String().Numeric().OneOf("123")).Throws<ConflictingAnyConstraintException>();
Check.ThatCode(() => Any.String().WithMaxLength(10).OneOf("x")).Throws<ConflictingAnyConstraintException>();
}

[Fact(DisplayName = "OneOf rejects null, empty, or null-containing value lists as arguments.")]
public void RejectsInvalidValueLists() {
Check.ThatCode(() => Any.String().OneOf()).Throws<ArgumentException>();
Check.ThatCode(() => Any.String().OneOf(null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => Any.String().OneOf("a", null!)).Throws<ArgumentException>();
}

[Fact(DisplayName = "OneOf accepts a sequence, drawing only from its values.")]
public void AcceptsASequence() {
IEnumerable<string> vendors = new List<string> { "Apple", "Microsoft", "Google" };

HashSet<string> 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<string>)null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => Any.String().OneOf(Enumerable.Empty<string>())).Throws<ArgumentException>();
Check.ThatCode(() => Any.String().OneOf(new List<string> { "a", null! })).Throws<ArgumentException>();
}

[Fact(DisplayName = "The sequence overload is terminal too: it conflicts after another constraint.")]
public void SequenceOverloadIsTerminal() {
Check.ThatCode(() => Any.String().NonEmpty().OneOf(new List<string> { "a", "b" })).Throws<ConflictingAnyConstraintException>();
}

}
39 changes: 39 additions & 0 deletions Dummies/AnyString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@
return value.ToString(CultureInfo.InvariantCulture);
}

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

Check warning on line 45 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 45 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 45 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 52 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 52 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 52 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 +202,45 @@
return new AnyString(_source, _spec.WithCasing(LetterCasing.Upper, "UpperCase()"));
}

/// <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
/// name). This is a <b>terminal</b> constraint: the supplied values are the whole specification, so it does not
/// combine with the shape, length or character constraints — declare it directly on <see cref="Any.String" />.
/// Duplicate values are collapsed; the generated string is one of the distinct values, drawn uniformly and
/// reproducibly under a seed.
/// </summary>
/// <param name="values">The values the generated string is drawn from; duplicates are ignored.</param>
/// <returns>A terminal generator drawing from <paramref name="values" />.</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>
/// <exception cref="ConflictingAnyConstraintException">Thrown when a constraint is already declared: a terminal value set cannot be combined with another constraint.</exception>
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());
}

/// <summary>
/// Draws the string from an explicit, fixed set of <paramref name="values" /> — the
/// <see cref="IEnumerable{T}" /> counterpart of <see cref="OneOf(string[])" />, 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.
/// </summary>
/// <param name="values">The values the generated string is drawn from; duplicates are ignored.</param>
/// <returns>A terminal generator drawing from <paramref name="values" />.</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>
/// <exception cref="ConflictingAnyConstraintException">Thrown when a constraint is already declared: a terminal value set cannot be combined with another constraint.</exception>
public AnyStringOneOf OneOf(IEnumerable<string> values) {
if (values is null) { throw new ArgumentNullException(nameof(values)); }

return OneOf(values as string[] ?? values.ToArray());
}

/// <inheritdoc />
public string Generate() {
return _spec.Generate(_source.Current.Random);
Expand Down
53 changes: 53 additions & 0 deletions Dummies/AnyStringOneOf.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Dummies;

/// <summary>
/// A generator of arbitrary strings drawn from an <b>explicit, fixed set of values</b> — 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 <i>terminal</i> generator:
/// unlike <see cref="AnyString" /> 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
/// <c>As(...)</c> into a value object, make it optional with <c>OrNull()</c>, or fold it into <c>Combine(...)</c>
/// and the collection generators.
/// </summary>
/// <remarks>
/// <para>
/// Each <see cref="Generate" /> 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 (<c>SetOf</c>, a dictionary's keys) gates against.
/// </para>
/// <example>
/// <code>
/// string vendor = Any.String().OneOf("Apple", "Microsoft", "Google").Generate();
/// IAny&lt;Currency&gt; currency = Any.String().OneOf("EUR", "USD", "GBP").As(Currency.Create);
/// </code>
/// </example>
/// </remarks>
public sealed class AnyStringOneOf : IAny<string>, IHasRandomSource, ICardinalityHint<string> {

#region Fields declarations

private readonly RandomSource _source;
private readonly IReadOnlyList<string> _values;

#endregion

internal AnyStringOneOf(RandomSource source, IReadOnlyList<string> 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<string>.DistinctCardinality => _values.Count;

bool ICardinalityHint<string>.Contains(string value) => _values.Contains(value, StringComparer.Ordinal);

/// <inheritdoc />
public string Generate() {
return _values[_source.Current.Random.Next(_values.Count)];
}

}
5 changes: 5 additions & 0 deletions Dummies/README.nuget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Dummies/StringSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@

#endregion

private StringSpec(int? exactLength, string? exactConstraint,

Check warning on line 62 in Dummies/StringSpec.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Constructor has 15 parameters, which is greater than the 7 authorized.

Check warning on line 62 in Dummies/StringSpec.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Constructor has 15 parameters, which is greater than the 7 authorized.
int minLength, string? minConstraint,
int? maxLength, string? maxConstraint,
string? prefix, string? prefixConstraint,
Expand All @@ -84,6 +84,16 @@
_casingConstraint = casingConstraint;
}

/// <summary>
/// Whether no constraint has been declared yet — the pristine state a generator from <see cref="Any.String" />
/// starts in, before any fluent constraint narrows it. Used to keep a terminal constraint (<c>OneOf</c>) from
/// being combined with the shaping ones.
/// </summary>
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;

/// <summary>Fixes the exact length; declared once per generator.</summary>
internal StringSpec WithExactLength(int length, string applying) {
if (_exactConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_exactConstraint} is already defined."); }
Expand Down Expand Up @@ -209,7 +219,7 @@
return this;
}

private void ValidateLengthBounds(string applying) {

Check warning on line 222 in Dummies/StringSpec.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 222 in Dummies/StringSpec.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
if (_exactLength is int exact) {
if (exact < _minLength) {
throw new ConflictingAnyConstraintException(applying == _exactConstraint
Expand Down
Loading