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

using NFluent;

#endregion

namespace Dummies.UnitTests;

/// <summary>
/// Behaviour of the lattice constraints — <c>MultipleOf</c> on the integers, <c>WithScale</c> on
/// <see cref="decimal" />, and <c>WithGranularity</c> on the temporals: values are built directly on the grid in
/// one draw, the grid composes with bounds/exclusions/allow-lists, and an empty grid conflicts eagerly.
/// </summary>
public sealed class AnyLatticeConstraintTests {

private const int SampleCount = 200;

#region MultipleOf

[Fact(DisplayName = "MultipleOf: every drawn value is a multiple of the step.")]
public void MultipleOfAlwaysDivisible() {
for (int i = 0; i < SampleCount; i++) {
Check.That(Any.Int32().MultipleOf(100).Generate() % 100).IsEqualTo(0);
Check.That(Any.Int64().Positive().MultipleOf(12L).Generate() % 12L).IsEqualTo(0L);
Check.That(Any.Byte().MultipleOf(5).Generate() % 5).IsEqualTo(0);
}
}

[Fact(DisplayName = "MultipleOf: draws on the grid within the declared range and reaches both ends.")]
public void MultipleOfHonoursRangeAndReachesBounds() {
HashSet<int> seen = new();
for (int i = 0; i < SampleCount; i++) {
int value = Any.Int32().Between(0, 1000).MultipleOf(100).Generate();
Check.That(value % 100).IsEqualTo(0);
Check.That(value >= 0 && value <= 1000).IsTrue();
seen.Add(value);
}

Check.That(seen.Contains(0)).IsTrue();
Check.That(seen.Contains(1000)).IsTrue();
}

[Fact(DisplayName = "MultipleOf: negative multiples are drawn on the grid too.")]
public void MultipleOfHandlesNegativeGrid() {
for (int i = 0; i < SampleCount; i++) {
int value = Any.Int32().Between(-100, -1).MultipleOf(10).Generate();
Check.That(value % 10).IsEqualTo(0);
Check.That(value >= -100 && value <= -10).IsTrue();
}
}

[Fact(DisplayName = "MultipleOf: composes with Except, never yielding an excluded grid point.")]
public void MultipleOfComposesWithExcept() {
HashSet<int> seen = new();
for (int i = 0; i < SampleCount; i++) {
int value = Any.Int32().Between(0, 30).MultipleOf(10).Except(10, 20).Generate();
Check.That(value % 10).IsEqualTo(0);
Check.That(value != 10 && value != 20).IsTrue();
seen.Add(value);
}

Check.That(seen.SetEquals([0, 30])).IsTrue();
}

[Fact(DisplayName = "MultipleOf: filters a OneOf allow-list to its members on the grid.")]
public void MultipleOfFiltersAllowList() {
for (int i = 0; i < SampleCount; i++) {
int value = Any.Int32().OneOf(5, 10, 15, 20).MultipleOf(10).Generate();
Check.That(value == 10 || value == 20).IsTrue();
}
}

[Fact(DisplayName = "MultipleOf: one is a no-op; zero and negatives are rejected.")]
public void MultipleOfArguments() {
for (int i = 0; i < SampleCount; i++) { Any.Int32().MultipleOf(1).Generate(); } // no-op: any value

Check.ThatCode(() => Any.Int32().MultipleOf(0)).Throws<ArgumentOutOfRangeException>();
Check.ThatCode(() => Any.Int32().MultipleOf(-5)).Throws<ArgumentOutOfRangeException>();
Check.ThatCode(() => Any.Byte().MultipleOf(0)).Throws<ArgumentOutOfRangeException>();
}

[Fact(DisplayName = "MultipleOf: an empty grid inside the range conflicts eagerly, naming the step.")]
public void MultipleOfEmptyGridConflicts() {
ConflictingAnyConstraintException conflict = Assert.Throws<ConflictingAnyConstraintException>(
() => Any.Int32().Between(1, 9).MultipleOf(10));
Check.That(conflict.Message).Contains("MultipleOf(10)");

// The same emptiness, whichever order the two constraints arrive in.
Check.ThatCode(() => Any.Int32().MultipleOf(10).Between(1, 9)).Throws<ConflictingAnyConstraintException>();
}

[Fact(DisplayName = "MultipleOf: a second, different step is rejected as already declared.")]
public void MultipleOfDeclaredOnce() {
Check.ThatCode(() => Any.Int32().MultipleOf(4).MultipleOf(6)).Throws<ConflictingAnyConstraintException>();
// The same step twice is idempotent, not a conflict.
Check.That(Any.Int32().Between(0, 100).MultipleOf(10).MultipleOf(10).Generate() % 10).IsEqualTo(0);
}

[Fact(DisplayName = "MultipleOf: a distinct collection sees the grid cardinality.")]
public void MultipleOfFeedsCardinality() {
HashSet<int> seen = new();
for (int i = 0; i < SampleCount; i++) { seen.Add(Any.Int32().Between(0, 20).MultipleOf(10).Generate()); }

// Exactly the three grid points are reachable — the cardinality hint a distinct collection relies on.
Check.That(seen.SetEquals([0, 10, 20])).IsTrue();
}

#endregion

#region WithScale

[Fact(DisplayName = "WithScale: every drawn value lies on the 10^-scale grid.")]
public void WithScaleStaysOnGrid() {
for (int i = 0; i < SampleCount; i++) {
decimal amount = Any.Decimal().Between(0m, 1000m).WithScale(2).Generate();
Check.That(amount).IsEqualTo(Math.Round(amount, 2, MidpointRounding.ToEven));
Check.That(amount >= 0m && amount <= 1000m).IsTrue();

decimal whole = Any.Decimal().WithScale(0).Generate();
Check.That(whole).IsEqualTo(Math.Round(whole, 0, MidpointRounding.ToEven));
}
}

[Fact(DisplayName = "WithScale: reaches both ends of a narrow grid.")]
public void WithScaleReachesBounds() {
HashSet<decimal> seen = new();
for (int i = 0; i < SampleCount; i++) {
decimal value = Any.Decimal().Between(0m, 1m).WithScale(1).Generate();
Check.That(value).IsEqualTo(Math.Round(value, 1, MidpointRounding.ToEven));
seen.Add(value);
}

Check.That(seen.Contains(0m)).IsTrue();
Check.That(seen.Contains(1m)).IsTrue();
}

[Fact(DisplayName = "WithScale: composes with Except on the grid.")]
public void WithScaleComposesWithExcept() {
for (int i = 0; i < SampleCount; i++) {
decimal value = Any.Decimal().Between(0m, 1m).WithScale(1).Except(0.5m).Generate();
Check.That(value).IsEqualTo(Math.Round(value, 1, MidpointRounding.ToEven));
Check.That(value).IsNotEqualTo(0.5m);
}
}

[Fact(DisplayName = "WithScale: a scale outside [0, 28] is rejected.")]
public void WithScaleArguments() {
Check.ThatCode(() => Any.Decimal().WithScale(-1)).Throws<ArgumentOutOfRangeException>();
Check.ThatCode(() => Any.Decimal().WithScale(29)).Throws<ArgumentOutOfRangeException>();
Any.Decimal().WithScale(0).Generate();
Any.Decimal().WithScale(28).Generate();
}

[Fact(DisplayName = "WithScale: a range containing no grid point conflicts eagerly.")]
public void WithScaleEmptyGridConflicts() {
ConflictingAnyConstraintException conflict = Assert.Throws<ConflictingAnyConstraintException>(
() => Any.Decimal().Between(0.001m, 0.009m).WithScale(2));
Check.That(conflict.Message).Contains("WithScale(2)");
}

#endregion

#region WithGranularity

[Fact(DisplayName = "WithGranularity: every drawn instant/duration lands on the grid.")]
public void WithGranularityStaysOnGrid() {
long quarterHour = TimeSpan.FromMinutes(15).Ticks;
long oneSecond = TimeSpan.FromSeconds(1).Ticks;
for (int i = 0; i < SampleCount; i++) {
Check.That(Any.DateTime().WithGranularity(TimeSpan.FromMinutes(15)).Generate().Ticks % quarterHour).IsEqualTo(0L);
Check.That(Any.TimeSpan().WithGranularity(TimeSpan.FromSeconds(1)).Generate().Ticks % oneSecond).IsEqualTo(0L);
Check.That(Any.DateTimeOffset().WithGranularity(TimeSpan.FromSeconds(1)).Generate().UtcTicks % oneSecond).IsEqualTo(0L);
}
}

[Fact(DisplayName = "WithGranularity: composes with a range and stays aligned within it.")]
public void WithGranularityHonoursRange() {
DateTime start = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime end = new(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc);
long day = TimeSpan.FromDays(1).Ticks;
for (int i = 0; i < SampleCount; i++) {
DateTime value = Any.DateTime().Between(start, end).WithGranularity(TimeSpan.FromDays(1)).Generate();
Check.That(value.Ticks % day).IsEqualTo(0L);
Check.That(value >= start && value <= end).IsTrue();
}
}

[Fact(DisplayName = "WithGranularity: a non-positive granularity is rejected.")]
public void WithGranularityArguments() {
Check.ThatCode(() => Any.DateTime().WithGranularity(TimeSpan.Zero)).Throws<ArgumentOutOfRangeException>();
Check.ThatCode(() => Any.TimeSpan().WithGranularity(TimeSpan.FromTicks(-1))).Throws<ArgumentOutOfRangeException>();
}

[Fact(DisplayName = "WithGranularity: a window with no aligned instant conflicts eagerly.")]
public void WithGranularityEmptyGridConflicts() {
DateTime start = new(2026, 1, 1, 0, 0, 1, DateTimeKind.Utc);
DateTime end = new(2026, 1, 1, 0, 0, 2, DateTimeKind.Utc);

Check.ThatCode(() => Any.DateTime().Between(start, end).WithGranularity(TimeSpan.FromDays(1)))
.Throws<ConflictingAnyConstraintException>();
}

#endregion

#if NET8_0_OR_GREATER
#region Modern types (net8.0)

[Fact(DisplayName = "MultipleOf: the 128-bit integers draw on the grid too.")]
public void MultipleOfWideIntegers() {
for (int i = 0; i < SampleCount; i++) {
Check.That(Any.Int128().Positive().MultipleOf((Int128)1000).Generate() % 1000 == (Int128)0).IsTrue();
Check.That(Any.UInt128().MultipleOf((UInt128)7).Generate() % 7 == (UInt128)0).IsTrue();
}

Check.ThatCode(() => Any.Int128().Between((Int128)1, (Int128)9).MultipleOf((Int128)10)).Throws<ConflictingAnyConstraintException>();
}

[Fact(DisplayName = "WithGranularity: TimeOnly aligns to the grid.")]
public void WithGranularityTimeOnly() {
long oneSecond = TimeSpan.FromSeconds(1).Ticks;
for (int i = 0; i < SampleCount; i++) {
Check.That(Any.TimeOnly().WithGranularity(TimeSpan.FromSeconds(1)).Generate().Ticks % oneSecond).IsEqualTo(0L);
}
}

#endregion
#endif

}
86 changes: 60 additions & 26 deletions Dummies.UnitTests/SurfaceParityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,45 +86,79 @@ private static string Signature(MethodInfo method) {
#region Algebra parity: per-family constraint sets

// The constraint vocabulary each family declares, encoded once as data. This table is the specification; the
// test compares it against what each builder actually exposes through reflection.
private static readonly string[] SignedNumericAlgebra = [
// test compares it against what each builder actually exposes through reflection. Ordering here is irrelevant —
// the test compares sets. The lattice constraint splits what was once one signed-numeric family: only the
// integers carry MultipleOf, only Decimal carries WithScale, only the temporals carry WithGranularity.

// Signed integers: the bound/sign vocabulary plus the integer lattice MultipleOf.
private static readonly string[] SignedIntegerAlgebra = [
"Positive", "Negative", "Zero", "NonZero",
"GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo",
"Between", "OneOf", "Except", "DifferentFrom"
"Between", "MultipleOf", "OneOf", "Except", "DifferentFrom"
];

// Unsigned integers drop Positive/Negative (meaningless there — NonZero carries the intent).
private static readonly string[] UnsignedNumericAlgebra = [
// Unsigned integers drop Positive/Negative (meaningless there — NonZero carries the intent); they keep MultipleOf.
private static readonly string[] UnsignedIntegerAlgebra = [
"Zero", "NonZero",
"GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo",
"Between", "MultipleOf", "OneOf", "Except", "DifferentFrom"
];

// Binary floating-point carries the full signed vocabulary but no lattice: a grid of 10^-n over binary floats is a
// footgun (0.1 is not representable), so MultipleOf/WithScale are deliberately withheld.
private static readonly string[] FloatingPointAlgebra = [
"Positive", "Negative", "Zero", "NonZero",
"GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo",
"Between", "OneOf", "Except", "DifferentFrom"
];

// Decimal is the signed vocabulary plus the decimal scale lattice WithScale.
private static readonly string[] DecimalAlgebra = [
"Positive", "Negative", "Zero", "NonZero",
"GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo",
"Between", "OneOf", "Except", "DifferentFrom", "WithScale"
];

// TimeSpan is a signed magnitude with a temporal granularity lattice WithGranularity.
private static readonly string[] TimeSpanAlgebra = [
"Positive", "Negative", "Zero", "NonZero",
"GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo",
"Between", "OneOf", "Except", "DifferentFrom", "WithGranularity"
];

// Instant-like builders rename the bound family to domain vocabulary, with identical inclusive/exclusive
// semantics, and carry no Positive/Negative/Zero (an instant has no sign).
private static readonly string[] InstantAlgebra = [
"After", "AfterOrEqualTo", "Before", "BeforeOrEqualTo",
"Between", "OneOf", "Except", "DifferentFrom"
];

// Instants with sub-day tick precision also carry the temporal granularity lattice WithGranularity (DateOnly,
// already day-resolution, keeps the plain InstantAlgebra).
private static readonly string[] InstantWithGranularityAlgebra = [
"After", "AfterOrEqualTo", "Before", "BeforeOrEqualTo",
"Between", "OneOf", "Except", "DifferentFrom", "WithGranularity"
];

public static IEnumerable<object[]> Builders() {
// Signed integers, the continuous/decimal builders, and TimeSpan (a signed magnitude) share the full algebra.
yield return [typeof(AnyInt32), SignedNumericAlgebra];
yield return [typeof(AnySByte), SignedNumericAlgebra];
yield return [typeof(AnyInt16), SignedNumericAlgebra];
yield return [typeof(AnyInt64), SignedNumericAlgebra];
yield return [typeof(AnyDouble), SignedNumericAlgebra];
yield return [typeof(AnySingle), SignedNumericAlgebra];
yield return [typeof(AnyDecimal), SignedNumericAlgebra];
yield return [typeof(AnyTimeSpan), SignedNumericAlgebra];

yield return [typeof(AnyByte), UnsignedNumericAlgebra];
yield return [typeof(AnyUInt16), UnsignedNumericAlgebra];
yield return [typeof(AnyUInt32), UnsignedNumericAlgebra];
yield return [typeof(AnyUInt64), UnsignedNumericAlgebra];

yield return [typeof(AnyDateTime), InstantAlgebra];
yield return [typeof(AnyDateTimeOffset), InstantAlgebra];
// Signed integers carry MultipleOf; the binary floats do not; Decimal carries WithScale; TimeSpan (a signed
// magnitude) carries WithGranularity — the lattice constraint is what forks the former shared signed family.
yield return [typeof(AnyInt32), SignedIntegerAlgebra];
yield return [typeof(AnySByte), SignedIntegerAlgebra];
yield return [typeof(AnyInt16), SignedIntegerAlgebra];
yield return [typeof(AnyInt64), SignedIntegerAlgebra];
yield return [typeof(AnyDouble), FloatingPointAlgebra];
yield return [typeof(AnySingle), FloatingPointAlgebra];
yield return [typeof(AnyDecimal), DecimalAlgebra];
yield return [typeof(AnyTimeSpan), TimeSpanAlgebra];

yield return [typeof(AnyByte), UnsignedIntegerAlgebra];
yield return [typeof(AnyUInt16), UnsignedIntegerAlgebra];
yield return [typeof(AnyUInt32), UnsignedIntegerAlgebra];
yield return [typeof(AnyUInt64), UnsignedIntegerAlgebra];

yield return [typeof(AnyDateTime), InstantWithGranularityAlgebra];
yield return [typeof(AnyDateTimeOffset), InstantWithGranularityAlgebra];

// The remaining scalar builders each carry their own deliberate set.
yield return [typeof(AnyBoolean), new[] { "True", "False", "DifferentFrom" }];
Expand All @@ -142,11 +176,11 @@ public static IEnumerable<object[]> Builders() {
}];

#if NET8_0_OR_GREATER
yield return [typeof(AnyInt128), SignedNumericAlgebra];
yield return [typeof(AnyHalf), SignedNumericAlgebra];
yield return [typeof(AnyUInt128), UnsignedNumericAlgebra];
yield return [typeof(AnyInt128), SignedIntegerAlgebra];
yield return [typeof(AnyHalf), FloatingPointAlgebra];
yield return [typeof(AnyUInt128), UnsignedIntegerAlgebra];
yield return [typeof(AnyDateOnly), InstantAlgebra];
yield return [typeof(AnyTimeOnly), InstantAlgebra];
yield return [typeof(AnyTimeOnly), InstantWithGranularityAlgebra];
#endif
}

Expand Down
15 changes: 15 additions & 0 deletions Dummies/AnyByte.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,21 @@ public AnyByte Between(byte minimum, byte maximum) {
return new AnyByte(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint));
}

/// <summary>
/// Requires the value to be a multiple of <paramref name="value" /> — drawn directly on that lattice, so the
/// declared range keeps its meaning (unlike a post-hoc <c>As(x =&gt; x * k)</c> projection). Declared once per
/// generator.
/// </summary>
/// <param name="value">The lattice step; must be strictly positive. A value of <c>1</c> adds no constraint.</param>
/// <returns>A new generator carrying the added constraint.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="value" /> is not strictly positive.</exception>
/// <exception cref="ConflictingAnyConstraintException">Thrown when the constraint contradicts a constraint already declared.</exception>
public AnyByte MultipleOf(byte value) {
if (value == 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); }

return new AnyByte(_source, _spec.WithStep((ulong)value, Ord(0), $"MultipleOf({V(value)})"));
}

/// <summary>Requires the value to be one of the supplied values. Declared once per generator.</summary>
/// <param name="values">The allowed values; duplicates are ignored.</param>
/// <returns>A new generator carrying the added constraint.</returns>
Expand Down
Loading