diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fa99ad2b..5825bca8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -101,9 +101,10 @@ jobs:
# through its property tests, and JustDummies through its own contract suite (JustDummies.UnitTests), running on the
# netstandard2.0 asset .NET Framework consumers load — its net8-only tests are conditioned out (issue #215).
# JustDummies.Xunit is floored the same way: it ships netstandard2.0, so a .NET Framework consumer loads that
- # asset, and its adapter suite must prove it works there and not only on net10.
+ # asset, and its adapter suite must prove it works there and not only on net10. JustDummies.PropertyTests
+ # joins them so the generator INVARIANTS, not just the example-based contract, hold on the floor.
# A per-project loop (not a solution-wide -f net472, which would force the TFM onto the net10-only projects
- # and fail) keeps the net472 scope exactly these five.
+ # and fail) keeps the net472 scope exactly these six.
- name: Test the netstandard2.0 libraries on .NET Framework 4.7.2
shell: bash
run: |
@@ -113,6 +114,7 @@ jobs:
FirstClassErrors.PropertyTests \
FirstClassErrors.RequestBinder.PropertyTests \
JustDummies.UnitTests \
+ JustDummies.PropertyTests \
JustDummies.Xunit.UnitTests ; do
echo "::group::$proj (net472)"
dotnet test "$proj/$proj.csproj" -c Release -f net472 -p:EnableNet472Floor=true \
diff --git a/AGENTS.md b/AGENTS.md
index 7b8cbcc6..4199736b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,6 +8,12 @@ Two roles are covered: **writing code** and **reviewing pull requests**.
- .NET Standard 2.0 library. Errors are first-class, documented, diagnosable concepts.
- Build: `dotnet build FirstClassErrors.sln`
- Test: `dotnet test FirstClassErrors.sln` (analyzer tests: `dotnet test FirstClassErrors.Analyzers.UnitTests`).
+- Adding a `JustDummies` test? It belongs to exactly one of two suites:
+ `JustDummies.PropertyTests` for invariants that hold for every legal constraint
+ argument, `JustDummies.UnitTests` for specific named cases — message content,
+ argument validation, structural conventions, dated regressions. Read
+ [`doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md`](doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md)
+ first; the decision behind it is ADR-0040.
- Repository language is **English** (code, comments, commits, PRs, issues, and
review comments). French lives only in `doc/handwritten/for-users/README.fr.md` and must stay in sync
with the English README.
diff --git a/CLAUDE.md b/CLAUDE.md
index 1803198f..7ba54a5a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,6 +22,13 @@ errors should stay structured, documented, and close to the code.
* Test: `dotnet test FirstClassErrors.sln`
* Run the analyzer tests when touching analyzers:
`dotnet test FirstClassErrors.Analyzers.UnitTests`
+* `JustDummies` has two test suites, and a new test belongs to exactly one of them:
+ `JustDummies.PropertyTests` owns invariants that hold for every legal constraint
+ argument, `JustDummies.UnitTests` owns specific named cases (message content,
+ argument validation, structural conventions, dated regressions). The rule and how
+ to apply it are in
+ [`doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md`](doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md)
+ (decision: ADR-0040). Read it before adding a JustDummies test.
* Only report tests as passing if you actually ran the corresponding command.
* If you did not run a relevant command, say so explicitly.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ec5aac98..04c1f3e7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,6 +13,10 @@ library produces. This guide defines how commits are written here.
* Build: `dotnet build FirstClassErrors.sln`
* Test: `dotnet test FirstClassErrors.sln`
* Analyzer tests, when touching analyzers: `dotnet test FirstClassErrors.Analyzers.UnitTests`
+* `JustDummies` tests are split across two suites — properties for invariants that
+ hold for every constraint argument, examples for specific named cases. See
+ [Writing JustDummies tests](doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md)
+ before adding one.
See [`CLAUDE.md`](CLAUDE.md) for the project layout and the broader change
guidelines.
diff --git a/FirstClassErrors.sln b/FirstClassErrors.sln
index ee394bd5..e827b2b2 100644
--- a/FirstClassErrors.sln
+++ b/FirstClassErrors.sln
@@ -63,6 +63,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JustDummies.Xunit", "JustDu
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JustDummies.Xunit.UnitTests", "JustDummies.Xunit.UnitTests\JustDummies.Xunit.UnitTests.csproj", "{75FD5E2C-A871-4659-8A01-13461FED84EB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JustDummies.PropertyTests", "JustDummies.PropertyTests\JustDummies.PropertyTests.csproj", "{A09FC4C6-1470-4768-A1C0-2D4E42788FCF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -359,6 +361,18 @@ Global
{75FD5E2C-A871-4659-8A01-13461FED84EB}.Release|x64.Build.0 = Release|Any CPU
{75FD5E2C-A871-4659-8A01-13461FED84EB}.Release|x86.ActiveCfg = Release|Any CPU
{75FD5E2C-A871-4659-8A01-13461FED84EB}.Release|x86.Build.0 = Release|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Debug|x64.Build.0 = Debug|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Debug|x86.Build.0 = Debug|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Release|x64.ActiveCfg = Release|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Release|x64.Build.0 = Release|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Release|x86.ActiveCfg = Release|Any CPU
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -381,13 +395,14 @@ Global
{75EA57DD-0128-4EB9-ADBE-567BFF602A93} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
{F2C739E7-6F2E-4256-9C87-9D6F1168DF4A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{BC4F2BE8-55C9-4AA4-90FA-699EC83A8985} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
- {BF67DBAE-EBE1-45CE-86E5-B2E9E3D4EF6B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
- {75FD5E2C-A871-4659-8A01-13461FED84EB} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
{1FC2C501-8842-4ABE-845E-000ED6575DD6} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
{D12EE45B-78F8-4CD4-8E67-E4A9DDA01AE6} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
{04707ACA-FB2E-43BF-A12C-28E01BF5F60D} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
{1201EA34-8F50-41B8-B829-FCCB62A5F14B} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
{74E68697-7C00-401C-84A9-7BF8DAFD9D34} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
+ {BF67DBAE-EBE1-45CE-86E5-B2E9E3D4EF6B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {75FD5E2C-A871-4659-8A01-13461FED84EB} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
+ {A09FC4C6-1470-4768-A1C0-2D4E42788FCF} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4988972E-3E0D-4F48-8656-0E67ECE994BF}
diff --git a/JustDummies.PropertyTests/CollectionProperties.cs b/JustDummies.PropertyTests/CollectionProperties.cs
new file mode 100644
index 00000000..0305254e
--- /dev/null
+++ b/JustDummies.PropertyTests/CollectionProperties.cs
@@ -0,0 +1,284 @@
+#region Usings declarations
+
+using FsCheck;
+using FsCheck.Fluent;
+
+using JetBrains.Annotations;
+
+#endregion
+
+namespace JustDummies.PropertyTests;
+
+///
+/// Property-based tests for the collection generators — , ,
+/// , and . The
+/// example-based suite pins a handful of hand-picked sizes (WithCount(5), WithCountBetween(4, 6))
+/// and can only prove the count algebra right for those; these quantify over the counts themselves — every size
+/// from empty to thirty, every ordered bound pair, every pool size against every requested count — so a count
+/// that is resolved one element short, or a distinctness gate that fires one element too early, is found and
+/// shrunk to its minimal counter-example.
+///
+///
+/// The count and the element domain are quantified together wherever they interact, because that is where
+/// the interesting behaviour lives: a distinct collection is satisfiable or contradictory depending on how the
+/// requested count compares to the cardinality its element generator advertises, and the library promises to
+/// decide that at declaration time rather than while drawing. A property that fixed either side would only ever
+/// visit one side of that frontier.
+///
+[TestSubject(typeof(AnyList))]
+public sealed class CollectionProperties {
+
+ #region Statics members declarations
+
+ ///
+ /// Draws per generator for the properties asserting over several collection shapes at once. Lower than the
+ /// shared default, so covering five shapes in one property costs about what one shape costs elsewhere.
+ ///
+ private const int DrawsPerShape = 4;
+
+ /// Negative counts, including the extremes an argument check that reasoned on magnitude would let through.
+ private static Gen NegativeCount() {
+ return Generators.WithEdges(Gen.Choose(-30, -1), int.MinValue, int.MinValue + 1, -1);
+ }
+
+ /// The pool 1..size — the same domain as Any.Int32().Between(1, size), held as an explicit set of values.
+ private static int[] Pool(int size) {
+ return Enumerable.Range(1, size).ToArray();
+ }
+
+ ///
+ /// Requires each of in turn, so a property can quantify over how many values
+ /// a collection is required to contain rather than pinning that number in the test.
+ ///
+ private static AnyList RequiringAll(AnyList generator, int[] values) {
+ AnyList required = generator;
+ foreach (int value in values) { required = required.Containing(value); }
+
+ return required;
+ }
+
+ #endregion
+
+ [Fact(DisplayName = "WithCount fixes the size exactly, for every count and every collection shape.")]
+ public void WithCountFixesTheSize() {
+ Prop.ForAll(Generators.Count(30).ToArbitrary(),
+ count => Expect.EveryDraw(Any.ListOf(Any.Int32()).WithCount(count), list => list.Count == count, DrawsPerShape)
+ && Expect.EveryDraw(Any.ArrayOf(Any.Int32()).WithCount(count), array => array.Length == count, DrawsPerShape)
+ && Expect.EveryDraw(Any.SequenceOf(Any.Int32()).WithCount(count), sequence => sequence.Count() == count, DrawsPerShape)
+ && Expect.EveryDraw(Any.SetOf(Any.Int32()).WithCount(count), set => set.Count == count, DrawsPerShape)
+ && Expect.EveryDraw(Any.DictionaryOf(Any.Int32(), Any.Int32()).WithCount(count), map => map.Count == count, DrawsPerShape))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithMinCount floors the size, for every minimum.")]
+ public void WithMinCountFloorsTheSize() {
+ Prop.ForAll(Generators.Count(30).ToArbitrary(),
+ minimum => Expect.EveryDraw(Any.ListOf(Any.Int32()).WithMinCount(minimum), list => list.Count >= minimum, DrawsPerShape)
+ && Expect.EveryDraw(Any.SetOf(Any.Int32()).WithMinCount(minimum), set => set.Count >= minimum, DrawsPerShape)
+ && Expect.EveryDraw(Any.DictionaryOf(Any.Int32(), Any.Int32()).WithMinCount(minimum), map => map.Count >= minimum, DrawsPerShape))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithMaxCount caps the size, for every maximum — including zero.")]
+ public void WithMaxCountCapsTheSize() {
+ Prop.ForAll(Generators.Count(30).ToArbitrary(),
+ maximum => Expect.EveryDraw(Any.ArrayOf(Any.Int32()).WithMaxCount(maximum), array => array.Length <= maximum, DrawsPerShape)
+ && Expect.EveryDraw(Any.SequenceOf(Any.Int32()).WithMaxCount(maximum), sequence => sequence.Count() <= maximum, DrawsPerShape)
+ && Expect.EveryDraw(Any.SetOf(Any.Int32()).WithMaxCount(maximum), set => set.Count <= maximum, DrawsPerShape))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithCountBetween keeps the size within its inclusive bounds, for every ordered pair.")]
+ public void WithCountBetweenStaysWithinItsBounds() {
+ // Degenerate pairs (min == max) are deliberately kept: a range that pins the count is the corner where a
+ // range resolved as half-open would show up as an off-by-one.
+ Prop.ForAll(Generators.OrderedPair(Generators.Count(30)).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.ListOf(Any.Int32()).WithCountBetween(bounds.Min, bounds.Max),
+ list => list.Count >= bounds.Min && list.Count <= bounds.Max, DrawsPerShape)
+ && Expect.EveryDraw(Any.DictionaryOf(Any.Int32(), Any.Int32()).WithCountBetween(bounds.Min, bounds.Max),
+ map => map.Count >= bounds.Min && map.Count <= bounds.Max, DrawsPerShape))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Crossed WithCountBetween arguments are an argument error, never a silent swap.")]
+ public void CrossedCountBoundsAreAnArgumentError() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Count(30)).ToArbitrary(),
+ bounds => bounds.Min == bounds.Max
+ || Expect.Throws(() => Any.ListOf(Any.Int32()).WithCountBetween(bounds.Max, bounds.Min)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A negative count is rejected as an argument error by every count method.")]
+ public void NegativeCountsAreAnArgumentError() {
+ // Argument validation precedes conflict checking, so these must be ArgumentOutOfRangeException whatever else
+ // the generator already carries — a negative count is never a "contradiction with a declared constraint".
+ Prop.ForAll(NegativeCount().ToArbitrary(),
+ count => Expect.Throws(() => Any.ListOf(Any.Int32()).WithCount(count))
+ && Expect.Throws(() => Any.SetOf(Any.Int32()).WithMinCount(count))
+ && Expect.Throws(() => Any.ArrayOf(Any.Int32()).WithMaxCount(count))
+ && Expect.Throws(() => Any.DictionaryOf(Any.Int32(), Any.Int32()).WithCountBetween(count, 0)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Empty always yields nothing and NonEmpty never does, whatever the element domain.")]
+ public void EmptyAndNonEmptyHoldOverEveryElementDomain() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Int32()).ToArbitrary(),
+ bounds => {
+ // A pinned element domain (Min == Max) is the corner that matters here: NonEmpty() must still
+ // resolve a count a distinct collection can fill, which for a single-value domain is exactly
+ // one element — not a conflict, and not an empty draw.
+ AnyInt32 element = Any.Int32().Between(bounds.Min, bounds.Max);
+
+ return Expect.EveryDraw(Any.ListOf(element).Empty(), list => list.Count == 0, DrawsPerShape)
+ && Expect.EveryDraw(Any.SetOf(element).Empty(), set => set.Count == 0, DrawsPerShape)
+ && Expect.EveryDraw(Any.DictionaryOf(element, Any.Int32()).Empty(), map => map.Count == 0, DrawsPerShape)
+ && Expect.EveryDraw(Any.ListOf(element).NonEmpty(), list => list.Count > 0, DrawsPerShape)
+ && Expect.EveryDraw(Any.ArrayOf(element).NonEmpty(), array => array.Length > 0, DrawsPerShape)
+ && Expect.EveryDraw(Any.SequenceOf(element).NonEmpty(), sequence => sequence.Any(), DrawsPerShape)
+ && Expect.EveryDraw(Any.SetOf(element).NonEmpty(), set => set.Count > 0, DrawsPerShape)
+ && Expect.EveryDraw(Any.DictionaryOf(element, Any.Int32()).NonEmpty(), map => map.Count > 0, DrawsPerShape);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Containing places the value and leaves the count untouched, for every value.")]
+ public void ContainingPlacesTheValue() {
+ Prop.ForAll((from value in Generators.Int32()
+ from count in Gen.Choose(1, 12)
+ select (value, count)).ToArbitrary(),
+ testCase => Expect.EveryDraw(Any.ListOf(Any.Int32()).WithCount(testCase.count).Containing(testCase.value),
+ list => list.Count == testCase.count && list.Contains(testCase.value), DrawsPerShape)
+ && Expect.EveryDraw(Any.ArrayOf(Any.Int32()).WithCount(testCase.count).Containing(testCase.value),
+ array => array.Length == testCase.count && array.Contains(testCase.value), DrawsPerShape)
+ && Expect.EveryDraw(Any.SequenceOf(Any.Int32()).WithCount(testCase.count).Containing(testCase.value),
+ sequence => sequence.Count() == testCase.count && sequence.Contains(testCase.value), DrawsPerShape)
+ && Expect.EveryDraw(Any.SetOf(Any.Int32()).WithCount(testCase.count).Containing(testCase.value),
+ set => set.Count == testCase.count && set.Contains(testCase.value), DrawsPerShape))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "ContainingKey places the key, and ContainingEntry pins exactly that mapping.")]
+ public void DictionaryContainmentPlacesTheKeyAndPinsTheEntry() {
+ Prop.ForAll((from key in Generators.Int32()
+ from value in Generators.Int32()
+ from count in Gen.Choose(1, 12)
+ select (key, value, count)).ToArbitrary(),
+ testCase => Expect.EveryDraw(Any.DictionaryOf(Any.Int32(), Any.Int32()).WithCount(testCase.count).ContainingKey(testCase.key),
+ map => map.Count == testCase.count && map.ContainsKey(testCase.key), DrawsPerShape)
+ && Expect.EveryDraw(Any.DictionaryOf(Any.Int32(), Any.Int32()).WithCount(testCase.count).ContainingEntry(testCase.key, testCase.value),
+ map => map.Count == testCase.count
+ && map.ContainsKey(testCase.key)
+ && map[testCase.key] == testCase.value, DrawsPerShape))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Distinct yields no duplicate and still honours the count, for every count the domain can hold.")]
+ public void DistinctYieldsNoDuplicateAndHonoursTheCount() {
+ Prop.ForAll((from count in Generators.Count(24)
+ from slack in Generators.Count(16)
+ select (count, slack)).ToArbitrary(),
+ testCase => {
+ // The domain is at its narrowest exactly one value wider than the request, so the count always
+ // fits — this property is about the dedup-draw filling it, not about the eager gate below. The
+ // tightest fits are where a fill that gave up early, or one that let a duplicate through, shows.
+ AnyInt32 element = Any.Int32().Between(1, testCase.count + testCase.slack + 1);
+
+ return Expect.EveryDraw(Any.ListOf(element).WithCount(testCase.count).Distinct(),
+ list => list.Count == testCase.count && new HashSet(list).Count == testCase.count, DrawsPerShape)
+ && Expect.EveryDraw(Any.ArrayOf(element).WithCount(testCase.count).Distinct(),
+ array => array.Length == testCase.count && new HashSet(array).Count == testCase.count, DrawsPerShape)
+ && Expect.EveryDraw(Any.SequenceOf(element).WithCount(testCase.count).Distinct(),
+ sequence => sequence.Count() == testCase.count && new HashSet(sequence).Count == testCase.count, DrawsPerShape);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A set and a dictionary are distinct by nature: their size still matches the requested count.")]
+ public void SetAndDictionaryAreDistinctByNature() {
+ Prop.ForAll((from count in Generators.Count(24)
+ from slack in Generators.Count(16)
+ select (count, slack)).ToArbitrary(),
+ testCase => {
+ AnyInt32 element = Any.Int32().Between(1, testCase.count + testCase.slack + 1);
+
+ // A HashSet collapses a repeated element silently and a Dictionary a repeated key, so a size
+ // equal to the request IS the distinctness assertion: a duplicate could only surface as a
+ // collection one element short of what was asked for.
+ return Expect.EveryDraw(Any.SetOf(element).WithCount(testCase.count),
+ set => set.Count == testCase.count, DrawsPerShape)
+ && Expect.EveryDraw(Any.DictionaryOf(element, Any.Int32()).WithCount(testCase.count),
+ map => map.Count == testCase.count, DrawsPerShape);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A distinct count beyond the element generator's advertised cardinality conflicts eagerly, and one within it generates.")]
+ public void DistinctCountBeyondTheAdvertisedCardinalityConflictsEagerly() {
+ Prop.ForAll((from poolSize in Gen.Choose(1, 8)
+ from count in Generators.Count(12)
+ select (poolSize, count)).ToArbitrary(),
+ testCase => {
+ // Two generators over the very same domain {1..poolSize}, one bounded and one pooled: both
+ // advertise a cardinality, so both must decide feasibility at declaration time. Quantifying
+ // over the pool size AND the requested count walks the whole frontier between the two verdicts
+ // — an example can only ever stand on one side of it.
+ AnyInt32 bounded = Any.Int32().Between(1, testCase.poolSize);
+ AnyOneOf pooled = Any.OneOf(Pool(testCase.poolSize));
+
+ if (testCase.count > testCase.poolSize) {
+ return Expect.Throws(() => Any.SetOf(bounded).WithCount(testCase.count))
+ && Expect.Throws(() => Any.SetOf(pooled).WithCount(testCase.count))
+ && Expect.Throws(() => Any.ListOf(pooled).WithCount(testCase.count).Distinct())
+ && Expect.Throws(() => Any.DictionaryOf(bounded, Any.Int32()).WithCount(testCase.count));
+ }
+
+ return Expect.EveryDraw(Any.SetOf(bounded).WithCount(testCase.count),
+ set => set.Count == testCase.count && set.All(value => value >= 1 && value <= testCase.poolSize), DrawsPerShape)
+ && Expect.EveryDraw(Any.SetOf(pooled).WithCount(testCase.count),
+ set => set.Count == testCase.count && set.All(value => value >= 1 && value <= testCase.poolSize), DrawsPerShape)
+ && Expect.EveryDraw(Any.ListOf(pooled).WithCount(testCase.count).Distinct(),
+ list => list.Count == testCase.count && new HashSet(list).Count == testCase.count, DrawsPerShape)
+ && Expect.EveryDraw(Any.DictionaryOf(bounded, Any.Int32()).WithCount(testCase.count),
+ map => map.Count == testCase.count, DrawsPerShape);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "More required values than WithMaxCount allows conflicts; up to that many are all placed.")]
+ public void RequiredValuesBeyondTheCountCapConflict() {
+ Gen requiredValues = Gen.NonEmptyListOf(Generators.Int32()).Select(drawn => drawn.Distinct().Take(6).ToArray());
+
+ Prop.ForAll((from values in requiredValues
+ from maximum in Generators.Count(8)
+ select (values, maximum)).ToArbitrary(),
+ testCase => {
+ // Each required value takes one element's room, so the verdict is decided by a single
+ // comparison — and the property holds it over every (how many, how big a cap) pair rather than
+ // over the one pair an example would pin.
+ if (testCase.values.Length > testCase.maximum) {
+ return Expect.Throws(
+ () => RequiringAll(Any.ListOf(Any.Int32()).WithMaxCount(testCase.maximum), testCase.values));
+ }
+
+ return Expect.EveryDraw(RequiringAll(Any.ListOf(Any.Int32()).WithMaxCount(testCase.maximum), testCase.values),
+ list => list.Count <= testCase.maximum
+ && testCase.values.All(value => list.Contains(value)));
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A generated sequence is fully materialized: enumerating it twice yields the same elements.")]
+ public void SequenceIsFullyMaterialized() {
+ Prop.ForAll(Generators.Count(30).ToArbitrary(),
+ count => {
+ IEnumerable sequence = Any.SequenceOf(Any.Int32()).WithCount(count).Generate();
+
+ List first = sequence.ToList();
+ List second = sequence.ToList();
+
+ return first.Count == count && first.SequenceEqual(second);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+}
diff --git a/JustDummies.PropertyTests/CompositionProperties.cs b/JustDummies.PropertyTests/CompositionProperties.cs
new file mode 100644
index 00000000..c45645ee
--- /dev/null
+++ b/JustDummies.PropertyTests/CompositionProperties.cs
@@ -0,0 +1,446 @@
+#region Usings declarations
+
+using FsCheck;
+using FsCheck.Fluent;
+
+using JetBrains.Annotations;
+
+#endregion
+
+namespace JustDummies.PropertyTests;
+
+///
+/// Property-based tests for the composition seams — ,
+/// OrNull(), , PairOf/TripleOf — and for the explicit
+/// pools (OneOf, ElementOf). Where the example-based suite pins one hand-picked constraint per part
+/// (Between(0, 100), WithLength(12)) and, at the higher arities, passes the same generator to
+/// every slot, these quantify over the constraint of each part independently — so a part routed to the wrong slot,
+/// a constraint dropped on the way through a seam, or a pool value invented out of nothing is found and shrunk to
+/// its minimal counter-example.
+///
+[TestSubject(typeof(AnyExtensions))]
+public sealed class CompositionProperties {
+
+ #region Statics members declarations
+
+ /// Arbitrary non-empty pools of integers — the explicit domains OneOf and ElementOf draw from.
+ private static Gen IntegerPools() {
+ return Gen.NonEmptyListOf(Generators.Int32()).Select(values => values.Take(24).ToArray());
+ }
+
+ ///
+ /// Arbitrary non-empty pools of non-null strings. The values are built from a drawn number rather than taken
+ /// from FsCheck's own string generator, which yields null — an element the library rejects by design, and
+ /// a case the dedicated null-element property covers on purpose rather than by accident.
+ ///
+ private static Gen StringPools() {
+ return Gen.NonEmptyListOf(Gen.Choose(0, 20).Select(value => "v" + value)).Select(values => values.Take(24).ToArray());
+ }
+
+ ///
+ /// A copy of carrying a null at (clamped to the pool's
+ /// length), so the null-element rejection is exercised at every position rather than only at the end.
+ ///
+ private static string[] Poisoned(string[] pool, int index) {
+ List poisoned = new(pool);
+ poisoned.Insert(Math.Min(index, poisoned.Count), null!);
+
+ return poisoned.ToArray();
+ }
+
+ /// A generator pinned to a single value — one distinct part per slot, so a mis-routed slot changes the result.
+ private static IAny Pinned(int value) {
+ return Any.Int32().Between(value, value);
+ }
+
+ #endregion
+
+ [Fact(DisplayName = "As projects every draw: the composed value is the factory's image of a value the source constraint allows.")]
+ public void AsProjectsEveryDrawThroughTheFactory() {
+ // Doubling is invertible, so the projected value can be mapped back and checked against the source interval —
+ // which is what "the image of a value satisfying the source constraint" means, stated without naming the draw.
+ Prop.ForAll(Generators.OrderedPair(Generators.Int32()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.Int32().Between(bounds.Min, bounds.Max).As(value => (long)value * 2),
+ projected => projected % 2 == 0
+ && projected / 2 >= bounds.Min
+ && projected / 2 <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "As bridges to a value object: constraints inside the factory's invariant always pass, constraints entirely outside it always fail.")]
+ public void AsBridgesToAValueObjectFactory() {
+ Prop.ForAll((from inside in Generators.OrderedPair(Gen.Choose(0, 100))
+ from outside in Generators.OrderedPair(Gen.Choose(101, 100_000))
+ select (inside, outside)).ToArbitrary(),
+ testCase => {
+ bool accepted = Expect.EveryDraw(Any.Int32().Between(testCase.inside.Min, testCase.inside.Max).As(Ratio.Create),
+ ratio => ratio.Value >= testCase.inside.Min && ratio.Value <= testCase.inside.Max);
+
+ // Constraints weaker than the invariant the factory enforces are the documented cause of a
+ // generation failure. Entirely outside the window every draw is rejected, so the wrap is
+ // certain rather than probable — no interval in the quantified space can slip through.
+ bool rejected = Expect.Throws(
+ () => Any.Int32().Between(testCase.outside.Min, testCase.outside.Max).As(Ratio.Create).Generate());
+
+ return accepted && rejected;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A factory that throws surfaces as AnyGenerationException carrying the original failure and the seed that replays it.")]
+ public void AsWrapsFactoryFailuresPreservingTheCause() {
+ Prop.ForAll((from bounds in Generators.OrderedPair(Generators.Int32())
+ from seed in Generators.Seed()
+ select (bounds, seed)).ToArbitrary(),
+ testCase => {
+ IAny generator = Any.WithSeed(testCase.seed)
+ .Int32()
+ .Between(testCase.bounds.Min, testCase.bounds.Max)
+ .As(_ => throw new FactoryRejection());
+
+ try {
+ generator.Generate();
+
+ return false;
+ } catch (AnyGenerationException exception) {
+ return exception.InnerException is FactoryRejection && exception.Seed == testCase.seed;
+ }
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "OrNull on a value type: every non-null draw satisfies the wrapped generator's constraint.")]
+ public void ValueTypeOrNullKeepsTheWrappedConstraint() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Int32()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.Int32().Between(bounds.Min, bounds.Max).OrNull(),
+ value => value is null || (value.Value >= bounds.Min && value.Value <= bounds.Max)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "OrNull on a reference type: every non-null draw satisfies the wrapped generator's constraint.")]
+ public void ReferenceTypeOrNullKeepsTheWrappedConstraint() {
+ Prop.ForAll(Gen.Choose(1, 12).ToArbitrary(),
+ length => Expect.EveryDraw(Any.String().WithLength(length).OrNull(),
+ value => value is null || value.Length == length))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "OrNull is a coin flip: over enough draws from any seed, both the null and the value branch appear.")]
+ public void OrNullEventuallyYieldsBothBranches() {
+ // The null decision is an even coin flip, so 64 draws miss a branch with probability about 2^-63 — vanishing,
+ // but a probability nonetheless. Drawing from an Any.WithSeed(...) context removes the residual flakiness: each
+ // FsCheck case is a fixed, replayable run, so a case that passes passes identically on every execution.
+ Prop.ForAll(Generators.Seed().ToArbitrary(),
+ seed => {
+ AnyContext context = Any.WithSeed(seed);
+
+ List values = Expect.Draws(context.Int32().Between(1, 100).OrNull(), 64);
+ List references = Expect.Draws(context.String().WithLength(4).OrNull(), 64);
+
+ return values.Any(value => value is null)
+ && values.Any(value => value is not null)
+ && values.All(value => value is null || (value.Value >= 1 && value.Value <= 100))
+ && references.Any(reference => reference is null)
+ && references.Any(reference => reference is not null)
+ && references.All(reference => reference is null || reference.Length == 4);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A null draw from OrNull does not consume a value from the wrapped generator.")]
+ public void OrNullDoesNotConsumeTheWrappedGeneratorOnANullDraw() {
+ // Counting the wrapped generator's draws is the only way to observe this: the wrapped values themselves cannot
+ // distinguish "not drawn" from "drawn and discarded".
+ Prop.ForAll(Gen.Choose(1, 40).ToArbitrary(),
+ drawCount => {
+ CountingAny wrapped = new(7);
+
+ List values = Expect.Draws(wrapped.OrNull(), drawCount);
+
+ return wrapped.Draws == values.Count(value => value is not null)
+ && values.All(value => value is null || value.Value == 7);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Combine of two parts: the composed value carries each part's own constraint.")]
+ public void CombineOfTwoPartsCarriesBothConstraints() {
+ Gen<(int Min, int Max)> intervals = Generators.OrderedPair(Generators.Int32());
+
+ Prop.ForAll((from first in intervals
+ from second in intervals
+ select (first, second)).ToArbitrary(),
+ testCase => Expect.EveryDraw(
+ Any.Combine(Any.Int32().Between(testCase.first.Min, testCase.first.Max),
+ Any.Int32().Between(testCase.second.Min, testCase.second.Max),
+ (one, two) => (Head: one, Tail: two)),
+ composed => composed.Head >= testCase.first.Min
+ && composed.Head <= testCase.first.Max
+ && composed.Tail >= testCase.second.Min
+ && composed.Tail <= testCase.second.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Combine of three parts routes each constraint to its own slot, across types.")]
+ public void CombineOfThreePartsCarriesEveryConstraint() {
+ Gen<(int Min, int Max)> intervals = Generators.OrderedPair(Generators.Int32());
+
+ Prop.ForAll((from first in intervals
+ from length in Gen.Choose(1, 12)
+ from third in intervals
+ select (first, length, third)).ToArbitrary(),
+ testCase => Expect.EveryDraw(
+ Any.Combine(Any.Int32().Between(testCase.first.Min, testCase.first.Max),
+ Any.String().WithLength(testCase.length),
+ Any.Int32().Between(testCase.third.Min, testCase.third.Max),
+ (one, two, three) => (Head: one, Text: two, Tail: three)),
+ composed => composed.Head >= testCase.first.Min
+ && composed.Head <= testCase.first.Max
+ && composed.Text.Length == testCase.length
+ && composed.Tail >= testCase.third.Min
+ && composed.Tail <= testCase.third.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Combine at its arity ceiling passes every part to its own slot, in order.")]
+ public void CombineAtArityEightRoutesEveryPart() {
+ Gen pins = Generators.Int32();
+
+ Prop.ForAll((from one in pins
+ from two in pins
+ from three in pins
+ from four in pins
+ from five in pins
+ from six in pins
+ from seven in pins
+ from eight in pins
+ select new[] { one, two, three, four, five, six, seven, eight }).ToArbitrary(),
+ expected => {
+ // Each of the eight parts is pinned to a value of its own, so two slots swapped change the
+ // composed array. The example-based suite passes the SAME generator to all eight slots and
+ // therefore cannot see such a mix-up at all — only the arity itself.
+ IAny generator = Any.Combine(
+ Pinned(expected[0]), Pinned(expected[1]), Pinned(expected[2]), Pinned(expected[3]),
+ Pinned(expected[4]), Pinned(expected[5]), Pinned(expected[6]), Pinned(expected[7]),
+ (one, two, three, four, five, six, seven, eight) => new[] { one, two, three, four, five, six, seven, eight });
+
+ return Expect.EveryDraw(generator, parts => parts.SequenceEqual(expected), 4);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "PairOf and TripleOf keep every component within its own constraint.")]
+ public void PairOfAndTripleOfKeepEveryComponentConstraint() {
+ Gen<(int Min, int Max)> intervals = Generators.OrderedPair(Generators.Int32());
+
+ Prop.ForAll((from first in intervals
+ from length in Gen.Choose(1, 12)
+ from third in intervals
+ select (first, length, third)).ToArbitrary(),
+ testCase => {
+ AnyInt32 head = Any.Int32().Between(testCase.first.Min, testCase.first.Max);
+ AnyString text = Any.String().WithLength(testCase.length);
+ AnyInt32 tail = Any.Int32().Between(testCase.third.Min, testCase.third.Max);
+
+ bool pairs = Expect.EveryDraw(Any.PairOf(head, text),
+ pair => pair.Item1 >= testCase.first.Min
+ && pair.Item1 <= testCase.first.Max
+ && pair.Item2.Length == testCase.length);
+
+ bool triples = Expect.EveryDraw(Any.TripleOf(head, text, tail),
+ triple => triple.Item1 >= testCase.first.Min
+ && triple.Item1 <= testCase.first.Max
+ && triple.Item2.Length == testCase.length
+ && triple.Item3 >= testCase.third.Min
+ && triple.Item3 <= testCase.third.Max);
+
+ return pairs && triples;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "OneOf and ElementOf draw only from the pool they were given, whatever the pool and whichever overload.")]
+ public void PoolGeneratorsStayWithinTheirPool() {
+ Prop.ForAll(IntegerPools().ToArbitrary(),
+ pool => Expect.EveryDraw(Any.OneOf(pool), value => pool.Contains(value))
+ && Expect.EveryDraw(Any.ElementOf((IReadOnlyList)pool), value => pool.Contains(value))
+ && Expect.EveryDraw(Any.ElementOf(pool.Select(value => value)), value => pool.Contains(value)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "OneOf reaches exactly the distinct values of its pool: duplicates collapse, nothing is dropped and nothing is invented.")]
+ public void OneOfReachesExactlyTheDistinctValuesOfItsPool() {
+ // At most four distinct values over 96 draws: one of them is missed with probability at most 4 x (3/4)^96,
+ // about 1e-11. A seeded context turns that residual chance into a fixed, replayable run per FsCheck case.
+ // The pool is drawn from a four-value alphabet on purpose, so duplicates are the common case, not the corner.
+ Gen pools = Gen.NonEmptyListOf(Gen.Choose(0, 3)).Select(values => values.Take(6).ToArray());
+
+ Prop.ForAll((from pool in pools
+ from seed in Generators.Seed()
+ select (pool, seed)).ToArbitrary(),
+ testCase => {
+ HashSet distinct = new(testCase.pool);
+ HashSet drawn = new(Expect.Draws(Any.WithSeed(testCase.seed).OneOf(testCase.pool), 96));
+
+ return drawn.SetEquals(distinct);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Any.String().OneOf draws only from its value set, whatever the set and whichever overload.")]
+ public void StringOneOfStaysWithinItsValueSet() {
+ Prop.ForAll(StringPools().ToArbitrary(),
+ pool => Expect.EveryDraw(Any.String().OneOf(pool), value => pool.Contains(value))
+ && Expect.EveryDraw(Any.String().OneOf(pool.Select(value => value)), value => pool.Contains(value)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "ElementOf materializes its sequence once, however many values are drawn from it.")]
+ public void ElementOfMaterializesItsSequenceOnce() {
+ Prop.ForAll((from pool in IntegerPools()
+ from drawCount in Gen.Choose(1, 40)
+ select (pool, drawCount)).ToArbitrary(),
+ testCase => {
+ int enumerations = 0;
+
+ IEnumerable LazyPool() {
+ enumerations++;
+ foreach (int value in testCase.pool) {
+ yield return value;
+ }
+ }
+
+ AnyOneOf generator = Any.ElementOf(LazyPool());
+ List drawn = Expect.Draws(generator, testCase.drawCount);
+
+ // One enumeration at construction, none per draw: a lazy query re-run per draw would both cost
+ // and, for a non-deterministic source, silently change the pool between two values.
+ return enumerations == 1 && drawn.All(value => testCase.pool.Contains(value));
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A null anywhere in the pool is an argument error, at every position and on every pool entry point.")]
+ public void ANullPoolElementIsAnArgumentError() {
+ Prop.ForAll((from pool in StringPools()
+ from index in Gen.Choose(0, 24)
+ select (pool, index)).ToArbitrary(),
+ testCase => {
+ string[] poisoned = Poisoned(testCase.pool, testCase.index);
+
+ return Expect.Throws(() => Any.OneOf(poisoned))
+ && Expect.Throws(() => Any.ElementOf((IReadOnlyList)poisoned))
+ && Expect.Throws(() => Any.ElementOf(poisoned.Select(value => value)))
+ && Expect.Throws(() => Any.String().OneOf(poisoned));
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "An absent pool is a null-argument error and an empty one an argument error, on the ambient and seeded entry points alike.")]
+ public void AbsentAndEmptyPoolsAreArgumentErrors() {
+ // There is nothing to quantify inside the pool — it is absent or empty by definition — so the quantification
+ // runs over the context instead: Any and Any.WithSeed(...) mirror the same surface and must reject identically.
+ Prop.ForAll(Generators.Seed().ToArbitrary(),
+ seed => {
+ AnyContext context = Any.WithSeed(seed);
+
+ return Expect.Throws(() => Any.OneOf((string[])null!))
+ && Expect.Throws(() => Any.ElementOf((IReadOnlyList)null!))
+ && Expect.Throws(() => Any.ElementOf((IEnumerable)null!))
+ && Expect.Throws(() => Any.String().OneOf((string[])null!))
+ && Expect.Throws(() => context.OneOf((string[])null!))
+ && Expect.Throws(() => Any.OneOf())
+ && Expect.Throws(() => Any.ElementOf(new List()))
+ && Expect.Throws(() => Any.ElementOf(Enumerable.Empty()))
+ && Expect.Throws(() => Any.String().OneOf())
+ && Expect.Throws(() => context.ElementOf(new List()));
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "The composition seams reject a null generator or a null lambda, whatever the source constraint.")]
+ public void CompositionSeamsRejectNullArguments() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Int32()).ToArbitrary(),
+ bounds => {
+ AnyInt32 part = Any.Int32().Between(bounds.Min, bounds.Max);
+
+ return Expect.Throws(() => part.As(null!))
+ && Expect.Throws(() => AnyExtensions.As(null!, (int value) => value))
+ && Expect.Throws(() => ((IAny)null!).OrNull())
+ && Expect.Throws(() => ((IAny)null!).OrNull())
+ && Expect.Throws(() => Any.Combine(null!, part, (int one, int two) => one + two))
+ && Expect.Throws(() => Any.Combine(part, null!, (int one, int two) => one + two))
+ && Expect.Throws(() => Any.Combine(part, part, (Func)null!))
+ && Expect.Throws(() => Any.PairOf(part, (IAny)null!))
+ && Expect.Throws(() => Any.TripleOf(part, (IAny)null!, part))
+ && Expect.Throws(
+ () => Any.Combine(part, part, part, part, part, part, part, (IAny)null!,
+ (int one, int two, int three, int four, int five, int six, int seven, int eight) => one));
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ #region Nested types
+
+ ///
+ /// A minimal value object whose factory enforces an invariant — the shape As exists to bridge to, and the
+ /// one that tells a well-constrained source from a source weaker than the invariant.
+ ///
+ private sealed class Ratio {
+
+ #region Statics members declarations
+
+ internal static Ratio Create(int value) {
+ if (value is < 0 or > 100) { throw new ArgumentOutOfRangeException(nameof(value)); }
+
+ return new Ratio(value);
+ }
+
+ #endregion
+
+ private Ratio(int value) {
+ Value = value;
+ }
+
+ internal int Value { get; }
+
+ }
+
+ /// The failure a factory raises, distinguishable from anything the library itself could throw.
+ private sealed class FactoryRejection : Exception {
+
+ internal FactoryRejection() : base("The factory rejected the generated value.") { }
+
+ }
+
+ ///
+ /// A generator counting how many times it is asked for a value. Foreign on purpose — it implements
+ /// only — which is exactly what makes the count observable from outside the library.
+ ///
+ /// The type of the generated values.
+ private sealed class CountingAny : IAny {
+
+ #region Fields declarations
+
+ private readonly T _value;
+
+ #endregion
+
+ internal CountingAny(T value) {
+ _value = value;
+ }
+
+ internal int Draws { get; private set; }
+
+ ///
+ public T Generate() {
+ Draws++;
+
+ return _value;
+ }
+
+ }
+
+ #endregion
+
+}
diff --git a/JustDummies.PropertyTests/ContinuousIntervalProperties.cs b/JustDummies.PropertyTests/ContinuousIntervalProperties.cs
new file mode 100644
index 00000000..91a202ab
--- /dev/null
+++ b/JustDummies.PropertyTests/ContinuousIntervalProperties.cs
@@ -0,0 +1,355 @@
+#region Usings declarations
+
+using FsCheck;
+using FsCheck.Fluent;
+
+using JetBrains.Annotations;
+
+#endregion
+
+namespace JustDummies.PropertyTests;
+
+///
+/// Property-based tests for the continuous interval algebra — ,
+/// and . Where the example-based suite pins a couple of hand-picked ranges
+/// (Between(1, 2)), these quantify over the whole bound space: the finite ends of each domain, the
+/// off-by-one representable neighbours around them, degenerate pinned intervals, and the non-finite arguments the
+/// binary floating-point generators must refuse rather than propagate.
+///
+///
+/// The three types share one contract but not one engine — double and float ride a binary
+/// next-representable ladder, decimal expresses exclusive bounds as an inclusive bound plus a point
+/// exclusion — so each invariant is stated once per type rather than once for a representative. The last two
+/// properties are the reachability guard for issue #206, generalized: the historical defect was not that a draw
+/// left its range but that half of every range was silently unreachable, which only a property over the interval
+/// itself, not over a fixed one, can rule out across the constraint space.
+///
+[TestSubject(typeof(AnyDouble))]
+public sealed class ContinuousIntervalProperties {
+
+ #region Statics members declarations
+
+ ///
+ /// Draws per reachability case: large enough that a uniform sampler covers both halves of a range with
+ /// overwhelming probability, small enough that a hundred FsCheck cases stay cheap.
+ ///
+ private const int ReachabilityDrawCount = 300;
+
+ /// Arbitrary finite s — the Generators.Double() recipe on the narrow type.
+ private static Gen Singles() {
+ return Generators.WithEdges(ArbMap.Default.GeneratorFor().Where(value => !float.IsNaN(value) && !float.IsInfinity(value)),
+ float.MinValue, -1f, 0f, 1f, float.MaxValue);
+ }
+
+ ///
+ /// Arbitrary s of moderate magnitude, with a few decimal places. Used where a constraint
+ /// adds a point exclusion (GreaterThan, LessThan): has no
+ /// next-representable ladder, so the engine steps a colliding draw by 1E-28 — an increment that vanishes
+ /// in rounding near and fails the generation loudly. That documented
+ /// extreme-magnitude behaviour is not the invariant under test, so the bounds stay well inside it.
+ ///
+ private static Gen ModerateDecimals() {
+ return Gen.Choose(-1_000_000_000, 1_000_000_000).Select(value => value / 1000m);
+ }
+
+ /// The three values a floating-point generator must refuse as a bound instead of quietly carrying.
+ private static Gen NonFiniteDoubles() {
+ return Gen.Elements(new[] { double.NaN, double.PositiveInfinity, double.NegativeInfinity });
+ }
+
+ /// The counterpart of .
+ private static Gen NonFiniteSingles() {
+ return Gen.Elements(new[] { float.NaN, float.PositiveInfinity, float.NegativeInfinity });
+ }
+
+ ///
+ /// Seeded, comfortably wide intervals at three magnitudes. Deliberately kept away from the
+ /// domain edges: reachability asks whether the sampler covers a range, and a midpoint taken over the full domain
+ /// cannot be formed without the arithmetic itself becoming the subject.
+ ///
+ private static Gen<(int Seed, double Min, double Max)> DoubleIntervals() {
+ return from seed in Generators.Seed()
+ from low in Gen.Choose(-1_000_000, 1_000_000)
+ from width in Gen.Choose(1, 1_000_000)
+ from unit in Gen.Elements(new[] { 0.0001d, 1d, 1000d })
+ select (Seed: seed, Min: low * unit, Max: (low + width) * unit);
+ }
+
+ /// The counterpart of .
+ private static Gen<(int Seed, decimal Min, decimal Max)> DecimalIntervals() {
+ return from seed in Generators.Seed()
+ from low in Gen.Choose(-1_000_000, 1_000_000)
+ from width in Gen.Choose(1, 1_000_000)
+ from unit in Gen.Elements(new[] { 0.0001m, 1m, 1000m })
+ select (Seed: seed, Min: low * unit, Max: (low + width) * unit);
+ }
+
+ #endregion
+
+ [Fact(DisplayName = "Unconstrained double and float draws are finite, whatever the seed.")]
+ public void UnconstrainedDrawsAreAlwaysFinite() {
+ Prop.ForAll(Generators.Seed().ToArbitrary(),
+ seed => {
+ AnyContext any = Any.WithSeed(seed);
+
+ return Expect.EveryDraw(any.Double(), value => !double.IsNaN(value) && !double.IsInfinity(value))
+ && Expect.EveryDraw(any.Single(), value => !float.IsNaN(value) && !float.IsInfinity(value));
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Double: Between contains — every draw falls within the declared inclusive bounds.")]
+ public void DoubleBetweenContainsEveryDraw() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Double()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.Double().Between(bounds.Min, bounds.Max),
+ value => value >= bounds.Min && value <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Single: Between contains — the quantized draw never escapes the bounds it was narrowed to.")]
+ public void SingleBetweenContainsEveryDraw() {
+ Prop.ForAll(Generators.OrderedPair(Singles()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.Single().Between(bounds.Min, bounds.Max),
+ value => value >= bounds.Min && value <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Decimal: Between contains, across the whole decimal range.")]
+ public void DecimalBetweenContainsEveryDraw() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Decimal()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.Decimal().Between(bounds.Min, bounds.Max),
+ value => value >= bounds.Min && value <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Double: Between with equal bounds pins the value, for every value.")]
+ public void DoubleBetweenWithEqualBoundsPins() {
+ Prop.ForAll(Generators.Double().ToArbitrary(),
+ value => Expect.EveryDraw(Any.Double().Between(value, value), drawn => drawn == value))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Single: Between with equal bounds pins the value, for every value.")]
+ public void SingleBetweenWithEqualBoundsPins() {
+ Prop.ForAll(Singles().ToArbitrary(),
+ value => Expect.EveryDraw(Any.Single().Between(value, value), drawn => drawn == value))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Decimal: Between with equal bounds pins the value, for every value.")]
+ public void DecimalBetweenWithEqualBoundsPins() {
+ Prop.ForAll(Generators.Decimal().ToArbitrary(),
+ value => Expect.EveryDraw(Any.Decimal().Between(value, value), drawn => drawn == value))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Double: GreaterThanOrEqualTo and LessThanOrEqualTo are inclusive — the bound itself stays legal.")]
+ public void DoubleInclusiveBoundsAreInclusive() {
+ Prop.ForAll(Generators.Double().ToArbitrary(),
+ bound => Expect.EveryDraw(Any.Double().GreaterThanOrEqualTo(bound), value => value >= bound)
+ && Expect.EveryDraw(Any.Double().LessThanOrEqualTo(bound), value => value <= bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Single: GreaterThanOrEqualTo and LessThanOrEqualTo are inclusive — the bound itself stays legal.")]
+ public void SingleInclusiveBoundsAreInclusive() {
+ Prop.ForAll(Singles().ToArbitrary(),
+ bound => Expect.EveryDraw(Any.Single().GreaterThanOrEqualTo(bound), value => value >= bound)
+ && Expect.EveryDraw(Any.Single().LessThanOrEqualTo(bound), value => value <= bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Decimal: GreaterThanOrEqualTo and LessThanOrEqualTo are inclusive — the bound itself stays legal.")]
+ public void DecimalInclusiveBoundsAreInclusive() {
+ Prop.ForAll(Generators.Decimal().ToArbitrary(),
+ bound => Expect.EveryDraw(Any.Decimal().GreaterThanOrEqualTo(bound), value => value >= bound)
+ && Expect.EveryDraw(Any.Decimal().LessThanOrEqualTo(bound), value => value <= bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Double: GreaterThan and LessThan are strict, and conflict at the finite ends of the domain.")]
+ public void DoubleStrictBoundsAreStrictAndConflictAtTheDomainEnds() {
+ Prop.ForAll(Generators.Double().ToArbitrary(),
+ bound => {
+ // Nothing representable lies above double.MaxValue or below double.MinValue, so the exclusive
+ // bound has no value left to name: a conflict at declaration, not an empty draw at generation.
+ bool above = bound == double.MaxValue
+ ? Expect.Throws(() => Any.Double().GreaterThan(bound))
+ : Expect.EveryDraw(Any.Double().GreaterThan(bound), value => value > bound);
+ bool below = bound == double.MinValue
+ ? Expect.Throws(() => Any.Double().LessThan(bound))
+ : Expect.EveryDraw(Any.Double().LessThan(bound), value => value < bound);
+
+ return above && below;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Single: GreaterThan and LessThan are strict on the float ladder, and conflict at its ends.")]
+ public void SingleStrictBoundsAreStrictAndConflictAtTheDomainEnds() {
+ Prop.ForAll(Singles().ToArbitrary(),
+ bound => {
+ // The step is taken on the float ladder, not the double one: a sub-ulp double step would
+ // re-quantize onto the same float and stall the strictness this asserts.
+ bool above = bound == float.MaxValue
+ ? Expect.Throws(() => Any.Single().GreaterThan(bound))
+ : Expect.EveryDraw(Any.Single().GreaterThan(bound), value => value > bound);
+ bool below = bound == float.MinValue
+ ? Expect.Throws(() => Any.Single().LessThan(bound))
+ : Expect.EveryDraw(Any.Single().LessThan(bound), value => value < bound);
+
+ return above && below;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Decimal: GreaterThan and LessThan are strict — the inclusive bound plus a point exclusion.")]
+ public void DecimalStrictBoundsAreStrict() {
+ Prop.ForAll(ModerateDecimals().ToArbitrary(),
+ bound => Expect.EveryDraw(Any.Decimal().GreaterThan(bound), value => value > bound)
+ && Expect.EveryDraw(Any.Decimal().LessThan(bound), value => value < bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Positive and Negative are strict, Zero pins and NonZero excludes — for all three types, whatever the seed.")]
+ public void SignConstraintsHoldForEverySeed() {
+ Prop.ForAll(Generators.Seed().ToArbitrary(),
+ seed => {
+ AnyContext any = Any.WithSeed(seed);
+
+ return Expect.EveryDraw(any.Double().Positive(), value => value > 0d)
+ && Expect.EveryDraw(any.Double().Negative(), value => value < 0d)
+ && Expect.EveryDraw(any.Double().Zero(), value => value == 0d)
+ && Expect.EveryDraw(any.Double().NonZero(), value => value != 0d)
+ && Expect.EveryDraw(any.Single().Positive(), value => value > 0f)
+ && Expect.EveryDraw(any.Single().Negative(), value => value < 0f)
+ && Expect.EveryDraw(any.Single().Zero(), value => value == 0f)
+ && Expect.EveryDraw(any.Single().NonZero(), value => value != 0f)
+ && Expect.EveryDraw(any.Decimal().Positive(), value => value > 0m)
+ && Expect.EveryDraw(any.Decimal().Negative(), value => value < 0m)
+ && Expect.EveryDraw(any.Decimal().Zero(), value => value == 0m)
+ && Expect.EveryDraw(any.Decimal().NonZero(), value => value != 0m);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Double: NaN and the infinities are rejected as argument errors by every entry point taking a bound.")]
+ public void DoubleRejectsNonFiniteArguments() {
+ Prop.ForAll((from finite in Generators.Double()
+ from nonFinite in NonFiniteDoubles()
+ select (finite, nonFinite)).ToArbitrary(),
+ testCase => Expect.Throws(() => Any.Double().GreaterThan(testCase.nonFinite))
+ && Expect.Throws(() => Any.Double().GreaterThanOrEqualTo(testCase.nonFinite))
+ && Expect.Throws(() => Any.Double().LessThan(testCase.nonFinite))
+ && Expect.Throws(() => Any.Double().LessThanOrEqualTo(testCase.nonFinite))
+ && Expect.Throws(() => Any.Double().Between(testCase.nonFinite, testCase.finite))
+ && Expect.Throws(() => Any.Double().Between(testCase.finite, testCase.nonFinite))
+ && Expect.Throws(() => Any.Double().OneOf(testCase.finite, testCase.nonFinite))
+ && Expect.Throws(() => Any.Double().Except(testCase.nonFinite))
+ && Expect.Throws(() => Any.Double().DifferentFrom(testCase.nonFinite)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Single: NaN and the infinities are rejected as argument errors by every entry point taking a bound.")]
+ public void SingleRejectsNonFiniteArguments() {
+ Prop.ForAll((from finite in Singles()
+ from nonFinite in NonFiniteSingles()
+ select (finite, nonFinite)).ToArbitrary(),
+ testCase => Expect.Throws(() => Any.Single().GreaterThan(testCase.nonFinite))
+ && Expect.Throws(() => Any.Single().GreaterThanOrEqualTo(testCase.nonFinite))
+ && Expect.Throws(() => Any.Single().LessThan(testCase.nonFinite))
+ && Expect.Throws(() => Any.Single().LessThanOrEqualTo(testCase.nonFinite))
+ && Expect.Throws(() => Any.Single().Between(testCase.nonFinite, testCase.finite))
+ && Expect.Throws(() => Any.Single().Between(testCase.finite, testCase.nonFinite))
+ && Expect.Throws(() => Any.Single().OneOf(testCase.finite, testCase.nonFinite))
+ && Expect.Throws(() => Any.Single().Except(testCase.nonFinite))
+ && Expect.Throws(() => Any.Single().DifferentFrom(testCase.nonFinite)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Double: crossed Between arguments are an argument error, never a silent swap.")]
+ public void DoubleCrossedBoundsAreAnArgumentError() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Double()).ToArbitrary(),
+ bounds => bounds.Min == bounds.Max
+ || Expect.Throws(() => Any.Double().Between(bounds.Max, bounds.Min)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Single: crossed Between arguments are an argument error, never a silent swap.")]
+ public void SingleCrossedBoundsAreAnArgumentError() {
+ Prop.ForAll(Generators.OrderedPair(Singles()).ToArbitrary(),
+ bounds => bounds.Min == bounds.Max
+ || Expect.Throws(() => Any.Single().Between(bounds.Max, bounds.Min)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Decimal: crossed Between arguments are an argument error, never a silent swap.")]
+ public void DecimalCrossedBoundsAreAnArgumentError() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Decimal()).ToArbitrary(),
+ bounds => bounds.Min == bounds.Max
+ || Expect.Throws(() => Any.Decimal().Between(bounds.Max, bounds.Min)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Double: OneOf draws only from the supplied pool, whatever the pool.")]
+ public void DoubleOneOfStaysWithinItsPool() {
+ Gen pools = Gen.NonEmptyListOf(Generators.Double()).Select(values => values.Distinct().ToArray());
+
+ Prop.ForAll(pools.ToArbitrary(),
+ pool => Expect.EveryDraw(Any.Double().OneOf(pool), value => pool.Contains(value)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Single: OneOf draws only from the supplied pool, whatever the pool.")]
+ public void SingleOneOfStaysWithinItsPool() {
+ Gen pools = Gen.NonEmptyListOf(Singles()).Select(values => values.Distinct().ToArray());
+
+ Prop.ForAll(pools.ToArbitrary(),
+ pool => Expect.EveryDraw(Any.Single().OneOf(pool), value => pool.Contains(value)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Decimal: OneOf draws only from the supplied pool, whatever the pool.")]
+ public void DecimalOneOfStaysWithinItsPool() {
+ Gen pools = Gen.NonEmptyListOf(Generators.Decimal()).Select(values => values.Distinct().ToArray());
+
+ Prop.ForAll(pools.ToArbitrary(),
+ pool => Expect.EveryDraw(Any.Decimal().OneOf(pool), value => pool.Contains(value)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Decimal: repeated draws over an arbitrary interval straddle its midpoint — neither half is unreachable.")]
+ public void DecimalBetweenReachesBothHalves() {
+ // Issue #206, generalized from its fixed 0..100 regression: the fraction was assembled from three
+ // non-negative Random.Next() draws, so each limb's top bit stayed zero, the fraction never crossed ~0.5,
+ // and every value of every range landed in its lower half. Membership held throughout — only reachability
+ // caught it, and only a property over the interval itself proves it for intervals nobody thought to pin.
+ Prop.ForAll(DecimalIntervals().ToArbitrary(),
+ interval => {
+ decimal midpoint = interval.Min / 2m + interval.Max / 2m;
+
+ List values = Expect.Draws(Any.WithSeed(interval.Seed).Decimal().Between(interval.Min, interval.Max),
+ ReachabilityDrawCount);
+
+ return values.Min() < midpoint && values.Max() > midpoint;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Double: repeated draws over an arbitrary interval straddle its midpoint — neither half is unreachable.")]
+ public void DoubleBetweenReachesBothHalves() {
+ // The binary engine samples as midpoint ± half rather than by interpolation, so it fails differently from
+ // the decimal one — which is exactly why the guard is stated per engine instead of once for a representative.
+ Prop.ForAll(DoubleIntervals().ToArbitrary(),
+ interval => {
+ double midpoint = interval.Min / 2d + interval.Max / 2d;
+
+ List values = Expect.Draws(Any.WithSeed(interval.Seed).Double().Between(interval.Min, interval.Max),
+ ReachabilityDrawCount);
+
+ return values.Min() < midpoint && values.Max() > midpoint;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+}
diff --git a/JustDummies.PropertyTests/Int32IntervalProperties.cs b/JustDummies.PropertyTests/Int32IntervalProperties.cs
new file mode 100644
index 00000000..33495a0f
--- /dev/null
+++ b/JustDummies.PropertyTests/Int32IntervalProperties.cs
@@ -0,0 +1,130 @@
+#region Usings declarations
+
+using FsCheck;
+using FsCheck.Fluent;
+
+using JetBrains.Annotations;
+
+#endregion
+
+namespace JustDummies.PropertyTests;
+
+///
+/// Property-based tests for 's interval algebra. Where the example-based suite pins a few
+/// hand-picked intervals, these quantify over the whole bound space — [int.MinValue, int.MaxValue],
+/// degenerate intervals, and the off-by-one edges around them — so a bound that overflows or truncates for one
+/// interval in a million is found and shrunk to its minimal counter-example rather than missed.
+///
+[TestSubject(typeof(AnyInt32))]
+public sealed class Int32IntervalProperties {
+
+ [Fact(DisplayName = "Between contains: every draw falls within the declared inclusive bounds.")]
+ public void BetweenContainsEveryDraw() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Int32()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.Int32().Between(bounds.Min, bounds.Max),
+ value => value >= bounds.Min && value <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Between with equal bounds pins the value, for every value.")]
+ public void BetweenWithEqualBoundsPins() {
+ Prop.ForAll(Generators.Int32().ToArbitrary(),
+ value => Expect.EveryDraw(Any.Int32().Between(value, value), drawn => drawn == value))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "GreaterThanOrEqualTo is inclusive: every draw is at least the bound.")]
+ public void GreaterThanOrEqualToIsInclusive() {
+ Prop.ForAll(Generators.Int32().ToArbitrary(),
+ bound => Expect.EveryDraw(Any.Int32().GreaterThanOrEqualTo(bound), value => value >= bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "LessThanOrEqualTo is inclusive: every draw is at most the bound.")]
+ public void LessThanOrEqualToIsInclusive() {
+ Prop.ForAll(Generators.Int32().ToArbitrary(),
+ bound => Expect.EveryDraw(Any.Int32().LessThanOrEqualTo(bound), value => value <= bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "GreaterThan is strict below int.MaxValue, and conflicts at it.")]
+ public void GreaterThanIsStrictAndConflictsAtTheCeiling() {
+ Prop.ForAll(Generators.Int32().ToArbitrary(),
+ bound => bound == int.MaxValue
+ ? Expect.Throws(() => Any.Int32().GreaterThan(bound))
+ : Expect.EveryDraw(Any.Int32().GreaterThan(bound), value => value > bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "LessThan is strict above int.MinValue, and conflicts at it.")]
+ public void LessThanIsStrictAndConflictsAtTheFloor() {
+ Prop.ForAll(Generators.Int32().ToArbitrary(),
+ bound => bound == int.MinValue
+ ? Expect.Throws(() => Any.Int32().LessThan(bound))
+ : Expect.EveryDraw(Any.Int32().LessThan(bound), value => value < bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Between never yields a value excluded by a subsequent Except.")]
+ public void ExceptRemovesTheValueFromTheInterval() {
+ Gen<(int Min, int Max)> intervals = Generators.OrderedPair(Generators.Count(40));
+
+ Prop.ForAll((from bounds in intervals
+ from excluded in Gen.Choose(bounds.Min, bounds.Max)
+ select (bounds, excluded)).ToArbitrary(),
+ testCase => {
+ // Excluding the single value of a pinned interval empties it: that is a conflict, not a draw.
+ if (testCase.bounds.Min == testCase.bounds.Max) {
+ return Expect.Throws(
+ () => Any.Int32().Between(testCase.bounds.Min, testCase.bounds.Max).Except(testCase.excluded));
+ }
+
+ return Expect.EveryDraw(Any.Int32().Between(testCase.bounds.Min, testCase.bounds.Max).Except(testCase.excluded),
+ value => value != testCase.excluded
+ && value >= testCase.bounds.Min
+ && value <= testCase.bounds.Max);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "OneOf draws only from the supplied pool, whatever the pool.")]
+ public void OneOfStaysWithinItsPool() {
+ Gen pools = Gen.NonEmptyListOf(Generators.Int32()).Select(values => values.Distinct().ToArray());
+
+ Prop.ForAll(pools.ToArbitrary(),
+ pool => Expect.EveryDraw(Any.Int32().OneOf(pool), value => pool.Contains(value)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Crossed Between arguments are an argument error, never a silent swap.")]
+ public void CrossedBoundsAreAnArgumentError() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Int32()).ToArbitrary(),
+ bounds => bounds.Min == bounds.Max
+ || Expect.Throws(() => Any.Int32().Between(bounds.Max, bounds.Min)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Bounds that cannot both hold conflict, for every crossed pair.")]
+ public void ImpossibleBoundPairsConflict() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Int32()).ToArbitrary(),
+ bounds => bounds.Min == bounds.Max
+ || Expect.Throws(
+ () => Any.Int32().GreaterThan(bounds.Max).LessThan(bounds.Min)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A generator is an immutable recipe: constraining it never narrows the original.")]
+ public void ConstrainingNeverMutatesTheOriginal() {
+ Prop.ForAll(Generators.OrderedPair(Generators.Count(60)).ToArbitrary(),
+ bounds => {
+ AnyInt32 original = Any.Int32().Between(bounds.Min, bounds.Max);
+ AnyInt32 narrowed = original.GreaterThanOrEqualTo(bounds.Max);
+
+ return !ReferenceEquals(original, narrowed)
+ && Expect.EveryDraw(original, value => value >= bounds.Min && value <= bounds.Max)
+ && Expect.EveryDraw(narrowed, value => value == bounds.Max);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+}
diff --git a/JustDummies.PropertyTests/JustDummies.PropertyTests.csproj b/JustDummies.PropertyTests/JustDummies.PropertyTests.csproj
new file mode 100644
index 00000000..92aecf31
--- /dev/null
+++ b/JustDummies.PropertyTests/JustDummies.PropertyTests.csproj
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+ enable
+ enable
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/JustDummies.PropertyTests/LatticeConstraintProperties.cs b/JustDummies.PropertyTests/LatticeConstraintProperties.cs
new file mode 100644
index 00000000..085765e5
--- /dev/null
+++ b/JustDummies.PropertyTests/LatticeConstraintProperties.cs
@@ -0,0 +1,352 @@
+#region Usings declarations
+
+using FsCheck;
+using FsCheck.Fluent;
+
+using JetBrains.Annotations;
+
+#endregion
+
+namespace JustDummies.PropertyTests;
+
+///
+/// Property-based tests for the three lattice constraints — MultipleOf on the integers,
+/// on , and WithGranularity on the temporals.
+/// Where the example-based suite pins a handful of hand-picked steps (MultipleOf(100),
+/// WithScale(2), WithGranularity(15 minutes)) and can only prove the grid right for those, these
+/// quantify over the whole step space: the step, the interval it must live inside, and the allow-list it must
+/// intersect are all drawn by FsCheck, so a grid that drifts off its anchor, empties without saying so, or
+/// silently escapes its declared range is found and shrunk to its minimal counter-example.
+///
+///
+/// Two rules shape almost every property here. A lattice is declared once, but the second declaration is
+/// only a conflict when it really is a second lattice — a step of one (and a granularity of one tick) is a
+/// no-op, and re-declaring the same step is idempotent — so the properties branch on the drawn value rather
+/// than assuming the call shape decides. And WithScale is a value lattice, not a representation
+/// contract: the drawn value lies on the 10^-scale grid but is not padded with trailing zeros, so it is
+/// checked with Math.Round(value, scale) and never through decimal.GetBits or ToString().
+///
+[TestSubject(typeof(AnyDecimal))]
+public sealed class LatticeConstraintProperties {
+
+ #region Statics members declarations
+
+ ///
+ /// A strictly positive lattice step, kept modest so the constrained domain never thins out to nothing and
+ /// the draw stays cheap — the invariant under test is about the grid, not about arithmetic at 2^31.
+ ///
+ private static Gen Steps() {
+ return Gen.Choose(1, 1000);
+ }
+
+ ///
+ /// A strictly positive granularity: fine tick-level steps mixed with the round durations real code asks for.
+ /// All stay far below the width of every temporal domain, so the lattice is never empty on its own.
+ ///
+ private static Gen Granularities() {
+ TimeSpan[] realistic = [
+ TimeSpan.FromTicks(1), TimeSpan.FromMilliseconds(1), TimeSpan.FromSeconds(1),
+ TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(15), TimeSpan.FromHours(1), TimeSpan.FromDays(1)
+ ];
+
+ return Gen.OneOf(Gen.Choose(1, 10000).Select(ticks => TimeSpan.FromTicks(ticks)), Gen.Elements(realistic));
+ }
+
+ ///
+ /// A granularity drawn from a deliberately tiny pool, so two independent draws collide often enough to
+ /// exercise the idempotent re-declaration alongside the conflicting one.
+ ///
+ private static Gen CollidingGranularities() {
+ TimeSpan[] pool = [TimeSpan.FromTicks(1), TimeSpan.FromMilliseconds(1), TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1)];
+
+ return Gen.Elements(pool);
+ }
+
+ ///
+ /// Whether the inclusive interval [, ] holds at least
+ /// one multiple of . The oracle strides down from the top of the interval in integer
+ /// arithmetic rather than reusing the library's own lattice walk, so it cannot inherit the very off-by-one it
+ /// is meant to catch.
+ ///
+ private static bool ContainsMultiple(int minimum, int maximum, int step) {
+ int remainder = maximum % step; // C# gives the remainder the sign of the dividend
+ int largest = maximum - (remainder < 0 ? remainder + step : remainder); // the largest multiple at or below the maximum
+
+ return largest >= minimum;
+ }
+
+ ///
+ /// One step of the 10^-scale grid, built by exact division so the oracle stays
+ /// free of the binary rounding a double power would smuggle in.
+ ///
+ private static decimal GridStep(int scale) {
+ decimal step = 1m;
+ for (int i = 0; i < scale; i++) { step /= 10m; }
+
+ return step;
+ }
+
+ #endregion
+
+ [Fact(DisplayName = "MultipleOf: every draw of every integer width lands on the grid, for every step.")]
+ public void MultipleOfLandsOnTheGrid() {
+ Prop.ForAll((from step in Steps()
+ from narrowStep in Gen.Choose(1, byte.MaxValue)
+ select (step, narrowStep)).ToArbitrary(),
+ testCase => {
+ int signedStep = testCase.step;
+ uint unsignedStep = (uint)testCase.step;
+ byte byteStep = (byte)testCase.narrowStep;
+
+ // The signed widths and the unsigned ones map onto the shared ordinal engine differently;
+ // the grid is anchored at zero for all of them, so the invariant reads the same everywhere.
+ return Expect.EveryDraw(Any.Int32().MultipleOf(signedStep), value => value % signedStep == 0)
+ && Expect.EveryDraw(Any.Int64().MultipleOf(signedStep), value => value % signedStep == 0)
+ && Expect.EveryDraw(Any.UInt32().MultipleOf(unsignedStep), value => value % unsignedStep == 0)
+ && Expect.EveryDraw(Any.Byte().MultipleOf(byteStep), value => value % byteStep == 0);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "MultipleOf: an interval keeps the grid inside it, and an interval holding no grid point conflicts.")]
+ public void MultipleOfComposesWithAnInterval() {
+ Prop.ForAll((from bounds in Generators.OrderedPair(Gen.Choose(-2000, 2000))
+ from step in Steps()
+ select (bounds, step)).ToArbitrary(),
+ testCase => {
+ int minimum = testCase.bounds.Min;
+ int maximum = testCase.bounds.Max;
+ int gridStep = testCase.step;
+
+ // An interval narrower than the step can fall entirely between two grid points — the whole
+ // point of drawing the bounds and the step independently. The lattice is then empty, and the
+ // library owes the caller a conflict at the fluent call, not a failure at Generate().
+ if (!ContainsMultiple(minimum, maximum, gridStep)) {
+ return Expect.Throws(() => Any.Int32().Between(minimum, maximum).MultipleOf(gridStep));
+ }
+
+ return Expect.EveryDraw(Any.Int32().Between(minimum, maximum).MultipleOf(gridStep),
+ value => value % gridStep == 0 && value >= minimum && value <= maximum);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "MultipleOf: an allow-list is filtered to its grid points, and an allow-list missing them conflicts.")]
+ public void MultipleOfFiltersAnAllowList() {
+ Gen pools = Gen.NonEmptyListOf(Gen.Choose(-200, 200)).Select(values => values.Distinct().ToArray());
+
+ Prop.ForAll((from pool in pools
+ from step in Gen.Choose(1, 20)
+ select (pool, step)).ToArbitrary(),
+ testCase => {
+ int[] survivors = testCase.pool.Where(value => value % testCase.step == 0).ToArray();
+
+ // Nothing in the pool on the grid means the intersection is empty: eager conflict, again at
+ // the call. The example-based suite can only pin one pool; this quantifies over all of them.
+ if (survivors.Length == 0) {
+ return Expect.Throws(
+ () => Any.Int32().OneOf(testCase.pool).MultipleOf(testCase.step));
+ }
+
+ return Expect.EveryDraw(Any.Int32().OneOf(testCase.pool).MultipleOf(testCase.step),
+ value => survivors.Contains(value));
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "MultipleOf: a step that is not strictly positive is an argument error, for every such step.")]
+ public void NonPositiveMultipleOfIsAnArgumentError() {
+ Prop.ForAll(Gen.Choose(-1000, 0).ToArbitrary(),
+ step => Expect.Throws(() => Any.Int32().MultipleOf(step))
+ && Expect.Throws(() => Any.Int64().MultipleOf(step)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "MultipleOf: a second, genuinely different step conflicts; a no-op or a repeat does not.")]
+ public void MultipleOfIsDeclaredOnce() {
+ Prop.ForAll((from first in Gen.Choose(1, 6)
+ from second in Gen.Choose(1, 6)
+ select (first, second)).ToArbitrary(),
+ testCase => {
+ int firstStep = testCase.first;
+ int secondStep = testCase.second;
+
+ // A step of one constrains nothing, and the same step twice is idempotent — neither is a
+ // second lattice. Only a real second lattice conflicts, so the verdict comes from the drawn
+ // values rather than from the call shape.
+ if (firstStep != secondStep && firstStep != 1 && secondStep != 1) {
+ return Expect.Throws(() => Any.Int32().MultipleOf(firstStep).MultipleOf(secondStep));
+ }
+
+ // In every accepted case exactly one of the two steps survives: the coarser one.
+ int surviving = Math.Max(firstStep, secondStep);
+
+ return Expect.EveryDraw(Any.Int32().MultipleOf(firstStep).MultipleOf(secondStep), value => value % surviving == 0);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithScale: every draw lies on the 10^-scale value grid, for every supported scale.")]
+ public void WithScaleLandsOnTheDecimalGrid() {
+ Prop.ForAll(Gen.Choose(0, 28).ToArbitrary(),
+ scale => Expect.EveryDraw(Any.Decimal().WithScale(scale),
+ // A value lattice: the value is expressible in `scale` decimals. Its
+ // rendered form is deliberately not asserted — the library pads nothing.
+ value => Math.Round(value, scale, MidpointRounding.ToEven) == value))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithScale: a grid-aligned interval keeps every draw both in range and on the grid.")]
+ public void WithScaleComposesWithABoundedInterval() {
+ Prop.ForAll((from scale in Gen.Choose(0, 28)
+ from start in Gen.Choose(-1000, 1000)
+ from width in Gen.Choose(0, 1000)
+ select (scale, start, width)).ToArbitrary(),
+ testCase => {
+ // Bounds placed ON the grid and only a few grid points apart: the window is genuinely narrow,
+ // so the draw has to land on one of a handful of points instead of anywhere in a vast range.
+ // A zero width pins the interval to a single grid point — the degenerate corner kept on purpose.
+ decimal step = GridStep(testCase.scale);
+ decimal minimum = testCase.start * step;
+ decimal maximum = (testCase.start + testCase.width) * step;
+
+ return Expect.EveryDraw(Any.Decimal().Between(minimum, maximum).WithScale(testCase.scale),
+ value => Math.Round(value, testCase.scale, MidpointRounding.ToEven) == value
+ && value >= minimum
+ && value <= maximum);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithScale: an interval lying strictly inside one grid cell conflicts at the call.")]
+ public void WithScaleOnAnIntervalWithoutGridPointConflicts() {
+ Prop.ForAll((from scale in Gen.Choose(0, 27)
+ from cell in Gen.Choose(-100, 100)
+ select (scale, cell)).ToArbitrary(),
+ testCase => {
+ // A window from a tenth to nine tenths of the way through one grid cell: it holds no value
+ // expressible in `scale` decimals, whichever cell and whichever scale were drawn. The scale
+ // stops at 27 so that a tenth of a step is still a representable decimal.
+ decimal step = GridStep(testCase.scale);
+ decimal finer = GridStep(testCase.scale + 1);
+ decimal lower = testCase.cell * step + finer;
+ decimal upper = testCase.cell * step + 9m * finer;
+
+ return Expect.Throws(
+ () => Any.Decimal().Between(lower, upper).WithScale(testCase.scale));
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithScale: a scale outside [0, 28] is an argument error, for every such scale.")]
+ public void WithScaleOutsideTheSupportedRangeIsAnArgumentError() {
+ Prop.ForAll(Gen.OneOf(Gen.Choose(-1000, -1), Gen.Choose(29, 1000)).ToArbitrary(),
+ scale => Expect.Throws(() => Any.Decimal().WithScale(scale)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithScale: a second, different scale conflicts; the same scale again does not.")]
+ public void WithScaleIsDeclaredOnce() {
+ Prop.ForAll((from first in Gen.Choose(0, 6)
+ from second in Gen.Choose(0, 6)
+ select (first, second)).ToArbitrary(),
+ testCase => {
+ // Unlike MultipleOf, no scale is a no-op: scale zero is the integer grid, a constraint in its
+ // own right. Only re-declaring the very same scale is idempotent.
+ if (testCase.first != testCase.second) {
+ return Expect.Throws(
+ () => Any.Decimal().WithScale(testCase.first).WithScale(testCase.second));
+ }
+
+ return Expect.EveryDraw(Any.Decimal().WithScale(testCase.first).WithScale(testCase.second),
+ value => Math.Round(value, testCase.first, MidpointRounding.ToEven) == value);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithGranularity: every draw sits on the grid anchored at its own type's origin.")]
+ public void WithGranularityLandsOnTheAnchoredGrid() {
+ Prop.ForAll(Granularities().ToArbitrary(),
+ granularity => {
+ long step = granularity.Ticks;
+
+ // The anchor is part of the contract and differs per type — TimeSpan.Zero for a duration,
+ // MinValue for an instant — so it is written out rather than folded into a bare modulo:
+ // an anchor drifting onto the wrong origin is exactly what this property exists to catch.
+ // AnyTimeSpan is the sharpest of the three, since its unconstrained domain is signed and a
+ // misplaced anchor shows up on the negative side.
+ return Expect.EveryDraw(Any.TimeSpan().WithGranularity(granularity),
+ value => (value.Ticks - TimeSpan.Zero.Ticks) % step == 0)
+ && Expect.EveryDraw(Any.DateTime().WithGranularity(granularity),
+ value => (value.Ticks - DateTime.MinValue.Ticks) % step == 0)
+ // The DateTimeOffset lattice lives on the instant, which is what its own ordering
+ // compares; unconstrained, the offset is TimeSpan.Zero and the two tick counts agree.
+ && Expect.EveryDraw(Any.DateTimeOffset().WithGranularity(granularity),
+ value => (value.UtcTicks - DateTimeOffset.MinValue.UtcTicks) % step == 0);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithGranularity: a range keeps the grid inside it, on all three temporal types.")]
+ public void WithGranularityComposesWithARange() {
+ Prop.ForAll((from granularity in Granularities()
+ from signedStart in Gen.Choose(-1000, 1000)
+ from unsignedStart in Gen.Choose(0, 1000)
+ from width in Gen.Choose(0, 1000)
+ select (granularity, signedStart, unsignedStart, width)).ToArbitrary(),
+ testCase => {
+ // Bounds a whole number of granularities from each anchor, a few grid points apart: the
+ // window is narrow enough that a draw off the grid, or one step outside the range, shows up.
+ // The instant types start at or after their own minimum; the duration one straddles zero.
+ long step = testCase.granularity.Ticks;
+ TimeSpan durationFrom = TimeSpan.FromTicks(testCase.signedStart * step);
+ TimeSpan durationTo = TimeSpan.FromTicks((testCase.signedStart + testCase.width) * step);
+ DateTime instantFrom = new(testCase.unsignedStart * step, DateTimeKind.Utc);
+ DateTime instantTo = new((testCase.unsignedStart + testCase.width) * step, DateTimeKind.Utc);
+ DateTimeOffset offsetFrom = new(instantFrom.Ticks, TimeSpan.Zero);
+ DateTimeOffset offsetTo = new(instantTo.Ticks, TimeSpan.Zero);
+
+ return Expect.EveryDraw(Any.TimeSpan().Between(durationFrom, durationTo).WithGranularity(testCase.granularity),
+ value => value.Ticks % step == 0 && value >= durationFrom && value <= durationTo)
+ && Expect.EveryDraw(Any.DateTime().Between(instantFrom, instantTo).WithGranularity(testCase.granularity),
+ value => value.Ticks % step == 0 && value >= instantFrom && value <= instantTo)
+ && Expect.EveryDraw(Any.DateTimeOffset().Between(offsetFrom, offsetTo).WithGranularity(testCase.granularity),
+ value => value.UtcTicks % step == 0 && value >= offsetFrom && value <= offsetTo);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithGranularity: a granularity that is not strictly positive is an argument error.")]
+ public void NonPositiveGranularityIsAnArgumentError() {
+ Prop.ForAll(Gen.Choose(-10000, 0).Select(ticks => TimeSpan.FromTicks(ticks)).ToArbitrary(),
+ granularity => Expect.Throws(() => Any.TimeSpan().WithGranularity(granularity))
+ && Expect.Throws(() => Any.DateTime().WithGranularity(granularity))
+ && Expect.Throws(() => Any.DateTimeOffset().WithGranularity(granularity)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "WithGranularity: a second, genuinely different granularity conflicts; a no-op or a repeat does not.")]
+ public void WithGranularityIsDeclaredOnce() {
+ Prop.ForAll((from first in CollidingGranularities()
+ from second in CollidingGranularities()
+ select (first, second)).ToArbitrary(),
+ testCase => {
+ TimeSpan declared = testCase.first;
+ TimeSpan redeclared = testCase.second;
+
+ // One tick constrains nothing, and the same granularity twice is idempotent — the same rule
+ // as MultipleOf, since both ride the one lattice the interval engine carries.
+ if (declared != redeclared && declared.Ticks != 1 && redeclared.Ticks != 1) {
+ return Expect.Throws(() => Any.TimeSpan().WithGranularity(declared).WithGranularity(redeclared))
+ && Expect.Throws(() => Any.DateTime().WithGranularity(declared).WithGranularity(redeclared));
+ }
+
+ long surviving = Math.Max(declared.Ticks, redeclared.Ticks);
+
+ return Expect.EveryDraw(Any.TimeSpan().WithGranularity(declared).WithGranularity(redeclared), value => value.Ticks % surviving == 0)
+ && Expect.EveryDraw(Any.DateTime().WithGranularity(declared).WithGranularity(redeclared), value => value.Ticks % surviving == 0);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+}
diff --git a/JustDummies.PropertyTests/ModernTypeInvariantProperties.cs b/JustDummies.PropertyTests/ModernTypeInvariantProperties.cs
new file mode 100644
index 00000000..1a24c20d
--- /dev/null
+++ b/JustDummies.PropertyTests/ModernTypeInvariantProperties.cs
@@ -0,0 +1,440 @@
+#region Usings declarations
+
+using FsCheck;
+using FsCheck.Fluent;
+
+using JetBrains.Annotations;
+
+#endregion
+
+namespace JustDummies.PropertyTests;
+
+///
+/// Property-based tests for the five generators the netstandard2.0 asset cannot carry —
+/// , , , and
+/// . Where the example-based suite pins one anchor date, one anchor time and a handful
+/// of tiny hand-picked intervals (Between(1, 3), Between(1f, 2f)), these draw the bounds, the
+/// lattice steps and the seeds themselves, over the whole 128-bit domain, the whole ten-thousand-year day-number
+/// range and the whole day of ticks, so a bound that overflows or truncates for one interval in a million is
+/// found and shrunk to its minimal counter-example rather than missed.
+///
+///
+/// The file is excluded from the .NET Framework 4.7.2 floor by the project file, because the types themselves
+/// are: its invariants are net8-and-later by construction, and the rest of the suite proves the netstandard2.0
+/// contract on the floor.
+///
+/// Three traps shape the properties here. The 128-bit generators ride an ordinal mapping (a sign-bit
+/// flip for , the identity for ), so the domain edges are exactly
+/// where the mapping could fold — the values are therefore assembled from two drawn 64-bit words, edges
+/// included, instead of from FsCheck's size-bounded numerics, which would never leave the neighbourhood of
+/// zero. carries about three decimal digits and tops out around 65504, so bounds are
+/// drawn as small whole numbers and the intervals stay deliberately coarse: the point is the interval
+/// algebra, not the rounding. And legality is value-dependent: an exclusive bound at the edge of a
+/// domain, or a ceiling on the wrong side of zero for Positive(), is a conflict rather than a
+/// narrowing, so the properties decide the expectation from the drawn value instead of assuming the call
+/// shape settles it.
+///
+///
+[TestSubject(typeof(AnyInt128))]
+public sealed class ModernTypeInvariantProperties {
+
+ #region Statics members declarations
+
+ /// The widest interval the exclusion property opens — small enough that excluding one value stays a visible event.
+ private const int MaxWindow = 40;
+
+ /// The domain split into blocks of a billion ticks: 863 * 1_000_000_000 + 999_999_999 is exactly 's tick count.
+ private const long TicksPerBlock = 1_000_000_000L;
+
+ /// The number of whole billion-tick blocks in a day — see .
+ private const int TickBlocks = 863;
+
+ /// The coarse magnitude the bounds stay within: every whole number up to 2048 is exactly representable, so a drawn bound survives the cast unrounded.
+ private const int MaxHalfMagnitude = 1024;
+
+ ///
+ /// A 64-bit word drawn over its whole range, assembled from three narrow draws so no single draw has to span
+ /// more than a 32-bit range. FsCheck's own numeric generators are size-bounded and cluster around zero, which
+ /// for the halves of a 128-bit value would mean "always a small number in a huge domain" — precisely the part
+ /// of the domain an ordinal mapping cannot get wrong.
+ ///
+ private static Gen Word64() {
+ return from high in Gen.Choose(0, (1 << 22) - 1)
+ from middle in Gen.Choose(0, (1 << 21) - 1)
+ from low in Gen.Choose(0, (1 << 21) - 1)
+ // Added rather than or-ed: the three fields occupy disjoint bit ranges, so the sum is the same
+ // word, without or-ing an operand the compiler sees as sign-extended (CS0675).
+ select ((ulong)high << 42) + ((ulong)middle << 21) + (ulong)low;
+ }
+
+ /// Arbitrary s over the whole domain, biased towards the ends of the range and towards the sign change at zero.
+ private static Gen Int128Values() {
+ Gen anywhere = from upper in Word64()
+ from lower in Word64()
+ select new Int128(upper, lower);
+
+ return Generators.WithEdges(anywhere, Int128.MinValue, Int128.MinValue + Int128.One, Int128.NegativeOne,
+ Int128.Zero, Int128.One, Int128.MaxValue - Int128.One, Int128.MaxValue);
+ }
+
+ /// Arbitrary s over the whole domain, biased towards the ends of the range — where an unsigned floor at zero hides its off-by-one.
+ private static Gen UInt128Values() {
+ Gen anywhere = from upper in Word64()
+ from lower in Word64()
+ select new UInt128(upper, lower);
+
+ return Generators.WithEdges(anywhere, UInt128.MinValue, UInt128.One, UInt128.MaxValue - UInt128.One, UInt128.MaxValue);
+ }
+
+ ///
+ /// Arbitrary bounds: whole numbers within a coarse magnitude, plus the edges of the type.
+ /// Coarse is deliberate — carries about three decimal digits, so a bound drawn with more
+ /// precision than that would be testing the cast rather than the interval algebra.
+ ///
+ private static Gen Halves() {
+ Gen anywhere = Gen.Choose(-MaxHalfMagnitude, MaxHalfMagnitude).Select(value => (Half)value);
+
+ return Generators.WithEdges(anywhere, Half.MinValue, Half.NegativeOne, Half.Zero, Half.Epsilon, Half.One, Half.MaxValue);
+ }
+
+ /// Arbitrary dates over the whole domain, its edges included: the day number is the ordinal the generator works in.
+ private static Gen Dates() {
+ Gen anywhere = Gen.Choose(DateOnly.MinValue.DayNumber, DateOnly.MaxValue.DayNumber).Select(dayNumber => DateOnly.FromDayNumber(dayNumber));
+
+ return Generators.WithEdges(anywhere, DateOnly.MinValue, DateOnly.MinValue.AddDays(1),
+ DateOnly.MaxValue.AddDays(-1), DateOnly.MaxValue);
+ }
+
+ ///
+ /// Arbitrary times of day over the whole domain, drawn as a tick count so the
+ /// sub-second end of the range is reached as often as the hours — a granularity property is only worth
+ /// anything when the values it constrains are tick-precise to begin with.
+ ///
+ private static Gen Times() {
+ Gen anywhere = from block in Gen.Choose(0, TickBlocks)
+ from offset in Gen.Choose(0, (int)TicksPerBlock - 1)
+ select new TimeOnly(block * TicksPerBlock + offset);
+
+ return Generators.WithEdges(anywhere, TimeOnly.MinValue, new TimeOnly(1),
+ new TimeOnly(TimeOnly.MaxValue.Ticks - 1), TimeOnly.MaxValue);
+ }
+
+ ///
+ /// Arbitrary lattice steps for , spanning the units a caller
+ /// actually asks for — a tick, a millisecond, a second, a minute, an hour — and deliberately reaching below
+ /// zero: a non-positive granularity is an argument error, and that half of the contract deserves the same
+ /// quantification as the lattice itself.
+ ///
+ private static Gen Granularities() {
+ Gen units = Gen.Elements(1L, TimeSpan.TicksPerMillisecond, TimeSpan.TicksPerSecond, TimeSpan.TicksPerMinute, TimeSpan.TicksPerHour);
+
+ return from unit in units
+ from count in Gen.Choose(-4, 24)
+ select TimeSpan.FromTicks(unit * count);
+ }
+
+ #endregion
+
+ [Fact(DisplayName = "Int128: Between contains — every draw falls within the declared inclusive bounds.")]
+ public void Int128BetweenContainsEveryDraw() {
+ Prop.ForAll(Generators.OrderedPair(Int128Values()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.Int128().Between(bounds.Min, bounds.Max),
+ value => value >= bounds.Min && value <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Int128: Between with equal bounds pins the value, for every value.")]
+ public void Int128BetweenWithEqualBoundsPins() {
+ Prop.ForAll(Int128Values().ToArbitrary(),
+ value => Expect.EveryDraw(Any.Int128().Between(value, value), drawn => drawn == value))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Int128: crossed Between arguments are an argument error, never a silent swap.")]
+ public void Int128CrossedBoundsAreAnArgumentError() {
+ Prop.ForAll(Generators.OrderedPair(Int128Values()).ToArbitrary(),
+ bounds => bounds.Min == bounds.Max
+ || Expect.Throws(() => Any.Int128().Between(bounds.Max, bounds.Min)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Int128: GreaterThan is strict below Int128.MaxValue, and conflicts at it.")]
+ public void Int128GreaterThanIsStrictAndConflictsAtTheCeiling() {
+ Prop.ForAll(Int128Values().ToArbitrary(),
+ bound => bound == Int128.MaxValue
+ ? Expect.Throws(() => Any.Int128().GreaterThan(bound))
+ : Expect.EveryDraw(Any.Int128().GreaterThan(bound), value => value > bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Int128: LessThan is strict above Int128.MinValue, and conflicts at it.")]
+ public void Int128LessThanIsStrictAndConflictsAtTheFloor() {
+ Prop.ForAll(Int128Values().ToArbitrary(),
+ bound => bound == Int128.MinValue
+ ? Expect.Throws(() => Any.Int128().LessThan(bound))
+ : Expect.EveryDraw(Any.Int128().LessThan(bound), value => value < bound))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Int128: Positive and Negative meet a bound on their own side of zero, and conflict with one on the other.")]
+ public void Int128SignConstraintsMeetABoundOrConflict() {
+ Prop.ForAll(Int128Values().ToArbitrary(),
+ bound => {
+ // Positive() has already pinned the minimum to one, so a ceiling at or below zero leaves the
+ // interval empty — and the library owes a conflict at the fluent call, not a failure at
+ // Generate(). The mirror image holds for Negative() and a floor at or above zero.
+ bool positive = bound <= Int128.Zero
+ ? Expect.Throws(() => Any.Int128().Positive().LessThanOrEqualTo(bound))
+ : Expect.EveryDraw(Any.Int128().Positive().LessThanOrEqualTo(bound),
+ value => value > Int128.Zero && value <= bound);
+ bool negative = bound >= Int128.Zero
+ ? Expect.Throws(() => Any.Int128().Negative().GreaterThanOrEqualTo(bound))
+ : Expect.EveryDraw(Any.Int128().Negative().GreaterThanOrEqualTo(bound),
+ value => value < Int128.Zero && value >= bound);
+
+ return positive && negative;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Int128: MultipleOf puts every draw on the lattice, and rejects a step that is not strictly positive.")]
+ public void Int128MultipleOfPutsEveryDrawOnTheGrid() {
+ Prop.ForAll(Int128Values().ToArbitrary(),
+ step => step <= Int128.Zero
+ ? Expect.Throws(() => Any.Int128().MultipleOf(step))
+ : Expect.EveryDraw(Any.Int128().MultipleOf(step), value => value % step == Int128.Zero))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Int128: Except removes the value from the interval, whatever the interval and the value.")]
+ public void Int128ExceptRemovesTheValueFromTheInterval() {
+ // The window is anchored on a drawn 64-bit value and widened by at most MaxWindow, so it reaches far into
+ // the 128-bit domain while its top can never overflow past Int128.MaxValue.
+ Gen<(Int128 Start, int Span, int Offset)> windows = from start in Generators.Int64()
+ from span in Gen.Choose(0, MaxWindow)
+ from offset in Gen.Choose(0, span)
+ select ((Int128)start, span, offset);
+
+ Prop.ForAll(windows.ToArbitrary(),
+ window => {
+ Int128 minimum = window.Start;
+ Int128 maximum = minimum + window.Span;
+ Int128 excluded = minimum + window.Offset;
+
+ // Excluding the single value of a pinned interval empties it: that is a conflict, not a draw.
+ if (window.Span == 0) {
+ return Expect.Throws(
+ () => Any.Int128().Between(minimum, maximum).Except(excluded));
+ }
+
+ return Expect.EveryDraw(Any.Int128().Between(minimum, maximum).Except(excluded),
+ value => value != excluded && value >= minimum && value <= maximum);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "UInt128: Between contains — every draw falls within the declared inclusive bounds.")]
+ public void UInt128BetweenContainsEveryDraw() {
+ Prop.ForAll(Generators.OrderedPair(UInt128Values()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.UInt128().Between(bounds.Min, bounds.Max),
+ value => value >= bounds.Min && value <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "UInt128: Between with equal bounds pins the value, for every value.")]
+ public void UInt128BetweenWithEqualBoundsPins() {
+ Prop.ForAll(UInt128Values().ToArbitrary(),
+ value => Expect.EveryDraw(Any.UInt128().Between(value, value), drawn => drawn == value))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "UInt128: the exclusive bounds are strict, and conflict at the ends of an unsigned domain.")]
+ public void UInt128ExclusiveBoundsAreStrictAndConflictAtTheDomainEdges() {
+ Prop.ForAll(UInt128Values().ToArbitrary(),
+ bound => {
+ // Nothing lies above UInt128.MaxValue, and — the unsigned specificity — nothing below zero:
+ // there the exclusive bound empties the domain rather than narrowing it.
+ bool above = bound == UInt128.MaxValue
+ ? Expect.Throws(() => Any.UInt128().GreaterThan(bound))
+ : Expect.EveryDraw(Any.UInt128().GreaterThan(bound), value => value > bound);
+ bool below = bound == UInt128.MinValue
+ ? Expect.Throws(() => Any.UInt128().LessThan(bound))
+ : Expect.EveryDraw(Any.UInt128().LessThan(bound), value => value < bound);
+
+ return above && below;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "UInt128: MultipleOf puts every draw on the lattice, and rejects a step of zero.")]
+ public void UInt128MultipleOfPutsEveryDrawOnTheGrid() {
+ Prop.ForAll(UInt128Values().ToArbitrary(),
+ step => step == UInt128.Zero
+ ? Expect.Throws(() => Any.UInt128().MultipleOf(step))
+ : Expect.EveryDraw(Any.UInt128().MultipleOf(step), value => value % step == UInt128.Zero))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "UInt128: OneOf draws only from the supplied pool, whatever the pool.")]
+ public void UInt128OneOfStaysWithinItsPool() {
+ Gen pools = Gen.NonEmptyListOf(UInt128Values()).Select(values => values.Distinct().ToArray());
+
+ Prop.ForAll(pools.ToArbitrary(),
+ pool => Expect.EveryDraw(Any.UInt128().OneOf(pool), value => pool.Contains(value)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Half: every draw is finite — NaN and the infinities are never generated, whatever the seed.")]
+ public void HalfDrawsAreAlwaysFiniteWhateverTheSeed() {
+ // Quantifying over the seed is what an example cannot do: the guarantee is about every sequence the
+ // generator can ever produce, not about the one the ambient context happens to produce today.
+ Prop.ForAll(Generators.Seed().ToArbitrary(),
+ seed => Expect.EveryDraw(Any.WithSeed(seed).Half(),
+ value => !Half.IsNaN(value) && !Half.IsInfinity(value)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Half: Between contains — every draw falls within the declared inclusive bounds.")]
+ public void HalfBetweenContainsEveryDraw() {
+ Prop.ForAll(Generators.OrderedPair(Halves()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.Half().Between(bounds.Min, bounds.Max),
+ value => value >= bounds.Min && value <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Half: crossed Between arguments are an argument error, not a conflict — argument validation comes first.")]
+ public void HalfCrossedBoundsAreAnArgumentError() {
+ Prop.ForAll(Generators.OrderedPair(Halves()).ToArbitrary(),
+ bounds => bounds.Min == bounds.Max
+ || Expect.Throws(() => Any.Half().Between(bounds.Max, bounds.Min)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Half: Positive and Negative meet a bound on their own side of zero, and conflict with one on the other.")]
+ public void HalfSignConstraintsMeetABoundOrConflict() {
+ Prop.ForAll(Halves().ToArbitrary(),
+ bound => {
+ // Positive() pins the minimum to the smallest representable half above zero, so no positive
+ // bound can ever fall below it: the legality line sits exactly at zero, on both sides.
+ bool positive = bound <= Half.Zero
+ ? Expect.Throws(() => Any.Half().Positive().LessThanOrEqualTo(bound))
+ : Expect.EveryDraw(Any.Half().Positive().LessThanOrEqualTo(bound),
+ value => value > Half.Zero && value <= bound);
+ bool negative = bound >= Half.Zero
+ ? Expect.Throws(() => Any.Half().Negative().GreaterThanOrEqualTo(bound))
+ : Expect.EveryDraw(Any.Half().Negative().GreaterThanOrEqualTo(bound),
+ value => value < Half.Zero && value >= bound);
+
+ return positive && negative;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Half: Zero pins the value, and only excluding zero itself empties it.")]
+ public void HalfZeroPinsUnlessTheExclusionEmptiesIt() {
+ Prop.ForAll(Halves().ToArbitrary(),
+ excluded => excluded == Half.Zero
+ ? Expect.Throws(() => Any.Half().Zero().DifferentFrom(excluded))
+ : Expect.EveryDraw(Any.Half().Zero().DifferentFrom(excluded), value => value == Half.Zero))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "DateOnly: Between contains — every draw falls within the declared inclusive dates.")]
+ public void DateOnlyBetweenContainsEveryDraw() {
+ Prop.ForAll(Generators.OrderedPair(Dates()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.DateOnly().Between(bounds.Min, bounds.Max),
+ value => value >= bounds.Min && value <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "DateOnly: Between with equal dates pins the value, for every date.")]
+ public void DateOnlyBetweenWithEqualBoundsPins() {
+ Prop.ForAll(Dates().ToArbitrary(),
+ date => Expect.EveryDraw(Any.DateOnly().Between(date, date), drawn => drawn == date))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "DateOnly: After and Before are exclusive, and conflict at the edges of the domain.")]
+ public void DateOnlyAfterAndBeforeAreExclusive() {
+ Prop.ForAll(Dates().ToArbitrary(),
+ date => {
+ // No date lies after DateOnly.MaxValue, and none before DateOnly.MinValue: there the
+ // exclusive bound empties the domain, and the library owes a conflict at the fluent call.
+ bool after = date == DateOnly.MaxValue
+ ? Expect.Throws(() => Any.DateOnly().After(date))
+ : Expect.EveryDraw(Any.DateOnly().After(date), value => value > date);
+ bool before = date == DateOnly.MinValue
+ ? Expect.Throws(() => Any.DateOnly().Before(date))
+ : Expect.EveryDraw(Any.DateOnly().Before(date), value => value < date);
+
+ return after && before;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "DateOnly: AfterOrEqualTo and BeforeOrEqualTo keep their own bound, for every date.")]
+ public void DateOnlyInclusiveBoundsKeepTheirEdge() {
+ Prop.ForAll(Dates().ToArbitrary(),
+ date => {
+ // A half-bounded draw only shows the bound is respected — an exclusive reading would pass
+ // that too. Closing the interval on the very same date is what proves it inclusive: read
+ // exclusively, those two constraints would leave nothing to draw.
+ bool lower = Expect.EveryDraw(Any.DateOnly().AfterOrEqualTo(date), value => value >= date);
+ bool upper = Expect.EveryDraw(Any.DateOnly().BeforeOrEqualTo(date), value => value <= date);
+ bool closed = Expect.EveryDraw(Any.DateOnly().AfterOrEqualTo(date).BeforeOrEqualTo(date), value => value == date);
+
+ return lower && upper && closed;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "DateOnly: crossed Between arguments are an argument error, never a silent swap.")]
+ public void DateOnlyCrossedBoundsAreAnArgumentError() {
+ Prop.ForAll(Generators.OrderedPair(Dates()).ToArbitrary(),
+ bounds => bounds.Min == bounds.Max
+ || Expect.Throws(() => Any.DateOnly().Between(bounds.Max, bounds.Min)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "TimeOnly: Between contains — every draw falls within the declared inclusive times of day.")]
+ public void TimeOnlyBetweenContainsEveryDraw() {
+ Prop.ForAll(Generators.OrderedPair(Times()).ToArbitrary(),
+ bounds => Expect.EveryDraw(Any.TimeOnly().Between(bounds.Min, bounds.Max),
+ value => value >= bounds.Min && value <= bounds.Max))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "TimeOnly: After and Before are exclusive, AfterOrEqualTo and BeforeOrEqualTo inclusive, at every time of day.")]
+ public void TimeOnlyBoundsCarryTheirInclusivity() {
+ Prop.ForAll(Times().ToArbitrary(),
+ time => {
+ // A time of day does not wrap: nothing lies after the last tick of the day, nor before
+ // midnight, so both exclusive bounds empty the domain at their own end of it.
+ bool after = time == TimeOnly.MaxValue
+ ? Expect.Throws(() => Any.TimeOnly().After(time))
+ : Expect.EveryDraw(Any.TimeOnly().After(time), value => value > time);
+ bool before = time == TimeOnly.MinValue
+ ? Expect.Throws(() => Any.TimeOnly().Before(time))
+ : Expect.EveryDraw(Any.TimeOnly().Before(time), value => value < time);
+ // Closing the interval on the very same time is what proves the other pair inclusive: read
+ // exclusively, those two constraints would leave nothing to draw.
+ bool closed = Expect.EveryDraw(Any.TimeOnly().AfterOrEqualTo(time).BeforeOrEqualTo(time), value => value == time);
+
+ return after && before && closed;
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "TimeOnly: WithGranularity puts every draw on the lattice anchored at midnight, and rejects a non-positive step.")]
+ public void TimeOnlyWithGranularityPutsEveryDrawOnTheLattice() {
+ // The anchor is TimeOnly.MinValue, not the drawn value: the lattice belongs to the domain, so a granularity
+ // yields the same grid whatever else has been declared — and the value is built on it, never snapped onto it.
+ Prop.ForAll(Granularities().ToArbitrary(),
+ granularity => granularity <= TimeSpan.Zero
+ ? Expect.Throws(() => Any.TimeOnly().WithGranularity(granularity))
+ : Expect.EveryDraw(Any.TimeOnly().WithGranularity(granularity),
+ value => (value.Ticks - TimeOnly.MinValue.Ticks) % granularity.Ticks == 0L))
+ .QuickCheckThrowOnFailure();
+ }
+
+}
diff --git a/JustDummies.PropertyTests/PatternRoundTripProperties.cs b/JustDummies.PropertyTests/PatternRoundTripProperties.cs
new file mode 100644
index 00000000..6ca16539
--- /dev/null
+++ b/JustDummies.PropertyTests/PatternRoundTripProperties.cs
@@ -0,0 +1,566 @@
+#region Usings declarations
+
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+using FsCheck;
+using FsCheck.Fluent;
+
+using JetBrains.Annotations;
+
+#endregion
+
+namespace JustDummies.PropertyTests;
+
+///
+/// Property-based tests for , built around a round trip: FsCheck generates a
+/// pattern from the supported regular subset, JustDummies generates a value from it, and the real .NET regex
+/// engine is asked whether that value matches. The example-based suite pins some fifty hand-written patterns and
+/// can only prove the walk right for those; here the pattern itself is the quantified variable, so a class,
+/// quantifier or grouping combination nobody thought to write down is reached, and a failure is shrunk to its
+/// minimal counter-example.
+///
+///
+///
+/// The oracle is ^(?:P)$ rather than P: JustDummies generates a whole matching string, so
+/// anchoring turns the partial-match into a whole-string test that catches
+/// under-generation (too few characters) and over-generation (trailing junk) alike, and keeps a top-level
+/// alternation from binding looser than intended.
+///
+///
+/// The pattern generator is deliberately narrower than the supported subset. It emits no anchors — the
+/// wrapper supplies them, and a generated ^ or $ would either duplicate them or land where the
+/// parser rightly refuses it — and it never nests an unbounded quantifier inside a repeated group, because
+/// (a+)+ legitimately overruns the generation ceiling with an .
+/// Unbounded quantifiers therefore apply to single-character atoms only, group repeats stay at or below two,
+/// and nesting stops at : a narrow round trip that always holds is worth more
+/// than a broad one that flakes.
+///
+///
+/// Every rejection is asserted by type, never on message text, and the taxonomy itself is under test:
+/// a well-formed but non-regular construct is an , while a pattern the
+/// real engine cannot compile is a plain . The two are not interchangeable, so
+/// the malformed property asks the real engine for its verdict first, and refuses an unsupported-construct
+/// answer.
+///
+///
+[TestSubject(typeof(AnyPattern))]
+public sealed class PatternRoundTripProperties {
+
+ /// How deep a generated pattern may nest groups. Shallow on purpose — see the class remarks.
+ private const int MaxNestingDepth = 3;
+
+ /// How many parts a generated concatenation may hold.
+ private const int MaxSequenceParts = 3;
+
+ /// How many branches a generated alternation may hold.
+ private const int MaxAlternationBranches = 3;
+
+ ///
+ /// How many repetitions above its minimum an unbounded quantifier may add — the library's own
+ /// RegexRepeat.UnboundedExtra, restated here because it is internal.
+ ///
+ private const int UnboundedExtra = 8;
+
+ ///
+ /// How many values the alternation-reachability property draws. Four branches missed by 120 uniform draws is a
+ /// one-in-a-quadrillion event, so the property is deterministic in practice while staying a genuine reachability
+ /// claim rather than a containment one.
+ ///
+ private const int BranchSampleCount = 120;
+
+ ///
+ /// Characters that stand for themselves in a pattern. Metacharacters are excluded (they appear only escaped),
+ /// and so are the space and #: those two are the only characters
+ /// reads differently, and the property that asserts that
+ /// option is refused must not risk the constructor throwing before JustDummies is reached.
+ ///
+ private const string LiteralAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_:/@=,;!~";
+
+ /// Letters and digits — what class ranges, hexadecimal escapes and alternation branches are built from.
+ private const string AlphaNumericAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+
+ /// A group name must open on a letter: a name opening on a digit is an explicit capture number instead.
+ private const string NameHeadAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+ /// After the first character a group name may hold any word character.
+ private const string NameTailAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789_";
+
+ #region Statics members declarations
+
+ ///
+ /// Escaped single characters: the metacharacters, plus the control escapes the parser resolves to a real
+ /// character rather than to its letter. \n and \r are left out — nothing in the subset needs
+ /// them, and a newline is the one character whose interaction with $ in the oracle is not a plain
+ /// end-of-string test.
+ ///
+ private static readonly string[] EscapedLiterals = {
+ @"\.", @"\*", @"\+", @"\?", @"\(", @"\)", @"\[", @"\]", @"\{", @"\}", @"\|", @"\\", @"\^", @"\$", @"\-", @"\/",
+ @"\t", @"\a", @"\f", @"\v", @"\e"
+ };
+
+ /// The class shorthands, all six of them.
+ private static readonly string[] Shorthands = { @"\d", @"\D", @"\w", @"\W", @"\s", @"\S" };
+
+ ///
+ /// The shorthands a negated class may hold. Only the positive ones: a negated class that excludes the
+ /// whole printable-ASCII universe ([^\s\S], [^\w\W]) is refused as unsupported, and excluding
+ /// digits, word characters and whitespace always leaves the punctuation behind.
+ ///
+ private static readonly string[] PositiveShorthands = { @"\d", @"\w", @"\s" };
+
+ ///
+ /// Escaped members a character class may hold. The control escapes are dropped and every punctuation member is
+ /// escaped, so no bare -, [ or ] can ever turn a member into a range endpoint, a
+ /// class subtraction or an early close.
+ ///
+ private static readonly string[] ClassEscapedMembers = {
+ @"\.", @"\*", @"\+", @"\?", @"\(", @"\)", @"\[", @"\]", @"\{", @"\}", @"\|", @"\\", @"\^", @"\$", @"\-", @"\/"
+ };
+
+ /// A class range stays inside one of these, so its endpoints are always in order and always readable.
+ private static readonly string[] RangeAlphabets = {
+ "abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "0123456789"
+ };
+
+ ///
+ /// Options that may accompany without changing whether the
+ /// pattern compiles, so the refusal is proven to depend on that one flag and not on being alone.
+ ///
+ private static readonly RegexOptions[] CompanionOptions = {
+ RegexOptions.None, RegexOptions.IgnoreCase, RegexOptions.Singleline, RegexOptions.Multiline,
+ RegexOptions.ExplicitCapture, RegexOptions.CultureInvariant
+ };
+
+ ///
+ /// Constructs the real engine compiles but JustDummies declines: they are well-formed, and either non-regular
+ /// (lookaround, backreference, balancing group, word boundary) or not honourable by a plain left-to-right walk
+ /// (atomic group, class subtraction, Unicode category, group option, conditional, comment).
+ ///
+ private static readonly string[] UnsupportedConstructs = {
+ "(?=abc)", "(?!abc)", "(?<=abc)", "(?abc)", "(?#note)", "(?i:abc)", "(?(a)b|c)",
+ @"\bword", @"\Bx", @"\Ax", @"x\z", @"x\Z", @"\Gx", @"\p{L}", @"\P{L}", @"(\w)\1", @"(?a)\k",
+ "(?y)?(?<-a>x)", "[a-z-[aeiou]]"
+ };
+
+ ///
+ /// Patterns the real .NET engine refuses to compile. JustDummies must mirror that verdict as a plain
+ /// — reporting them as unsupported would claim the caller wrote something
+ /// merely out of scope rather than something broken.
+ ///
+ private static readonly string[] MalformedPatterns = {
+ "[a-", "(abc", "abc)", "(?", "a{3,1}", "*abc", @"a\", "a*+", "a**", "a*??", "[]", @"\q", @"\x4", @"\c1",
+ "{2}", "(?<>a)", "(?<1a>x)", "(?<0>x)", "(?<01>x)", "(?'0'x)", "(?x)", "(?x)"
+ };
+
+ ///
+ /// The oracle: the pattern anchored at both ends, so a partial match cannot pass for a whole one. A match
+ /// timeout is attached as a safety net — a generated pattern that somehow made the backtracking engine crawl
+ /// should fail the suite, never hang it.
+ ///
+ private static Regex Anchored(string pattern, RegexOptions options) {
+ return new Regex("^(?:" + pattern + ")$", options, TimeSpan.FromSeconds(10));
+ }
+
+ /// Whether the real .NET engine compiles at all — the reference verdict on well-formedness.
+ private static bool CompilesInTheRealEngine(string pattern) {
+ try {
+ _ = new Regex(pattern);
+
+ return true;
+ } catch (ArgumentException) {
+ return false;
+ }
+ }
+
+ ///
+ /// Whether is refused as malformed — an .
+ /// An is explicitly not an acceptable answer: the two verdicts
+ /// say different things to the caller, so the taxonomy is asserted rather than merely "it threw".
+ ///
+ private static bool ThrowsMalformed(string pattern) {
+ try {
+ _ = Any.StringMatching(pattern);
+
+ return false;
+ } catch (UnsupportedRegexException) {
+ return false;
+ } catch (ArgumentException) {
+ return true;
+ }
+ }
+
+ /// An integer rendered for a quantifier bound, independently of the ambient culture.
+ private static string Digits(int value) {
+ return value.ToString(CultureInfo.InvariantCulture);
+ }
+
+ /// A pattern from the supported subset, nesting at most levels of groups.
+ private static Gen SupportedPattern() {
+ return Pattern(MaxNestingDepth);
+ }
+
+ ///
+ /// An alternation of one to branches. One branch is kept in the mix: the
+ /// unalternated pattern is the common case, and dropping it would leave every generated pattern lopsided.
+ ///
+ private static Gen Pattern(int depth) {
+ Gen branch = Sequence(depth);
+
+ return from count in Gen.Choose(1, MaxAlternationBranches)
+ from branches in Gen.ArrayOf(branch, count)
+ select string.Join("|", branches);
+ }
+
+ /// A concatenation of one to quantified atoms.
+ private static Gen Sequence(int depth) {
+ Gen part = Quantified(depth);
+
+ return from count in Gen.Choose(1, MaxSequenceParts)
+ from parts in Gen.ArrayOf(part, count)
+ select string.Concat(parts);
+ }
+
+ ///
+ /// An atom with an optional quantifier. The split between the two quantifier generators is what keeps the
+ /// recursion safe: only a single-character atom may carry an unbounded quantifier, so no (a+)+ — a
+ /// pattern that legitimately overruns the generation ceiling — can ever be built.
+ ///
+ private static Gen Quantified(int depth) {
+ Gen quantifiedLeaf = from atom in Leaf()
+ from quantifier in LeafQuantifier()
+ select atom + quantifier;
+
+ if (depth <= 0) { return quantifiedLeaf; }
+
+ Gen quantifiedGroup = from atom in Group(depth)
+ from quantifier in GroupQuantifier()
+ select atom + quantifier;
+
+ // Groups stay a minority of the atoms: the nesting is what makes a pattern expensive, both to generate and
+ // to match, and a suite of 100 cases is worth more spent on breadth than on depth.
+ return Gen.Frequency((6, quantifiedLeaf), (2, quantifiedGroup));
+ }
+
+ /// A group in each of the four supported forms: capturing, non-capturing, and named in both syntaxes.
+ private static Gen Group(int depth) {
+ Gen body = Pattern(depth - 1);
+
+ return from inner in body
+ from name in GroupName()
+ from kind in Gen.Choose(0, 3)
+ select kind switch {
+ 0 => "(" + inner + ")",
+ 1 => "(?:" + inner + ")",
+ 2 => "(?<" + name + ">" + inner + ")",
+ _ => "(?'" + name + "'" + inner + ")"
+ };
+ }
+
+ ///
+ /// A group name the real engine accepts: a letter followed by up to two word characters. Names opening on a
+ /// digit are left out — those are explicit capture numbers, with their own validity rules, and the
+ /// malformed property covers them instead.
+ ///
+ private static Gen GroupName() {
+ return from head in Gen.Elements(NameHeadAlphabet.ToCharArray())
+ from tail in Gen.ArrayOf(Gen.Elements(NameTailAlphabet.ToCharArray()), 2)
+ from length in Gen.Choose(0, 2)
+ select head.ToString() + new string(tail, 0, length);
+ }
+
+ ///
+ /// An atom that emits exactly one character: a literal, an escaped literal, a shorthand, a hexadecimal escape,
+ /// a character class or the dot.
+ ///
+ private static Gen Leaf() {
+ return Gen.Frequency((8, Gen.Elements(LiteralAlphabet.ToCharArray()).Select(character => character.ToString())),
+ (3, Gen.Elements(EscapedLiterals)),
+ (4, Gen.Elements(Shorthands)),
+ (2, HexEscape()),
+ (4, CharacterClass()),
+ (1, Gen.Constant(".")));
+ }
+
+ ///
+ /// Single-character atoms only — the subset the quantifier-length properties need, where the generated length
+ /// is the repetition count. Hexadecimal escapes are dropped so that a quantifier can never be mistaken
+ /// for a continuation of the escape's digits.
+ ///
+ private static Gen SingleCharacterAtom() {
+ return Gen.Frequency((4, Gen.Elements(LiteralAlphabet.ToCharArray()).Select(character => character.ToString())),
+ (2, Gen.Elements(EscapedLiterals)),
+ (2, Gen.Elements(Shorthands)),
+ (2, CharacterClass()),
+ (1, Gen.Constant(".")));
+ }
+
+ /// A \xHH or \uHHHH escape naming a letter or a digit, so the escaped character stays printable.
+ private static Gen HexEscape() {
+ return from character in Gen.Elements(AlphaNumericAlphabet.ToCharArray())
+ from wide in Gen.Elements(new[] { false, true })
+ select wide
+ ? @"\u" + ((int)character).ToString("X4", CultureInfo.InvariantCulture)
+ : @"\x" + ((int)character).ToString("X2", CultureInfo.InvariantCulture);
+ }
+
+ ///
+ /// A character class of one to three members, negated or not. A negated class draws from the restricted member
+ /// set (see ), so the negation always leaves characters to draw from.
+ ///
+ private static Gen CharacterClass() {
+ return from negated in Gen.Elements(new[] { false, true })
+ from count in Gen.Choose(1, 3)
+ from members in Gen.ArrayOf(ClassMember(negated), count)
+ select "[" + (negated ? "^" : string.Empty) + string.Concat(members) + "]";
+ }
+
+ /// One member of a character class: a single character, a range, a shorthand, or an escaped punctuation member.
+ private static Gen ClassMember(bool negatedClass) {
+ Gen single = Gen.Elements(AlphaNumericAlphabet.ToCharArray()).Select(character => character.ToString());
+ Gen shorthand = Gen.Elements(negatedClass ? PositiveShorthands : Shorthands);
+
+ if (negatedClass) { return Gen.Frequency((4, single), (3, ClassRange()), (2, shorthand)); }
+
+ return Gen.Frequency((4, single), (3, ClassRange()), (2, shorthand), (2, Gen.Elements(ClassEscapedMembers)));
+ }
+
+ /// A range whose endpoints come from the same alphabet, so the low endpoint never exceeds the high one.
+ private static Gen ClassRange() {
+ return from alphabet in Gen.Elements(RangeAlphabets)
+ from first in Gen.Choose(0, alphabet.Length - 1)
+ from second in Gen.Choose(0, alphabet.Length - 1)
+ select $"{alphabet[Math.Min(first, second)]}-{alphabet[Math.Max(first, second)]}";
+ }
+
+ ///
+ /// A quantifier for a single-character atom: nothing, ?, a bounded {n}/{n,m}, or an
+ /// unbounded */+/{n,}. An unbounded quantifier is safe here precisely because the atom
+ /// under it is one character wide.
+ ///
+ private static Gen LeafQuantifier() {
+ Gen bounded = from minimum in Gen.Choose(0, 2)
+ from extra in Gen.Choose(0, 2)
+ from exact in Gen.Elements(new[] { false, true })
+ select exact
+ ? "{" + Digits(minimum) + "}"
+ : "{" + Digits(minimum) + "," + Digits(minimum + extra) + "}";
+
+ Gen unbounded = Gen.OneOf(Gen.Elements(new[] { "*", "+" }),
+ Gen.Choose(0, 2).Select(minimum => "{" + Digits(minimum) + ",}"));
+
+ return WithOptionalLazyMarker(Gen.Frequency((5, Gen.Constant(string.Empty)),
+ (2, Gen.Constant("?")),
+ (2, bounded),
+ (2, unbounded)));
+ }
+
+ ///
+ /// A quantifier for a group: bounded only, and never above two repetitions. Both restrictions are about size —
+ /// an unbounded repeat of a group is the runaway case, and a large bounded one multiplies out just as fast.
+ ///
+ private static Gen GroupQuantifier() {
+ Gen bounded = from minimum in Gen.Choose(0, 1)
+ from extra in Gen.Choose(0, 1)
+ select "{" + Digits(minimum) + "," + Digits(minimum + extra) + "}";
+
+ return WithOptionalLazyMarker(Gen.Frequency((6, Gen.Constant(string.Empty)),
+ (2, Gen.Constant("?")),
+ (2, bounded)));
+ }
+
+ ///
+ /// Occasionally makes a quantifier lazy. A lazy marker changes which match the engine prefers, never which
+ /// strings match, so it must leave the generated language untouched — worth quantifying over rather than
+ /// assuming. It is never appended to an absent quantifier, where a bare ? would be a quantifier of its own.
+ ///
+ private static Gen WithOptionalLazyMarker(Gen quantifiers) {
+ return from quantifier in quantifiers
+ from lazy in Gen.Choose(0, 3)
+ select quantifier.Length == 0 || lazy != 0 ? quantifier : quantifier + "?";
+ }
+
+ /// A short literal word, the material of the alternation-reachability property's branches.
+ private static Gen Word() {
+ return from characters in Gen.ArrayOf(Gen.Elements(AlphaNumericAlphabet.ToCharArray()), 3)
+ from length in Gen.Choose(1, 3)
+ select new string(characters, 0, length);
+ }
+
+ #endregion
+
+ [Fact(DisplayName = "Round trip: every value generated from a supported pattern is fully matched by the real .NET engine.")]
+ public void EveryGeneratedValueIsMatchedByTheRealEngine() {
+ Prop.ForAll(SupportedPattern().ToArbitrary(),
+ pattern => {
+ // The oracle is built once per case and reused across the draws: it is the pattern that varies
+ // between cases, not between draws.
+ Regex oracle = Anchored(pattern, RegexOptions.None);
+
+ return Expect.EveryDraw(Any.StringMatching(pattern), oracle.IsMatch);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Round trip under IgnoreCase: every value is matched by the very regex it was generated from.")]
+ public void IgnoreCaseValuesAreMatchedByTheSameRegex() {
+ Prop.ForAll(SupportedPattern().ToArbitrary(),
+ pattern => {
+ // The Regex overload exists so a test can reuse the object its production code validates with,
+ // so the oracle is that same pattern under that same option — not a case-folded rewrite of it.
+ Regex source = new(pattern, RegexOptions.IgnoreCase);
+ Regex oracle = Anchored(pattern, RegexOptions.IgnoreCase);
+
+ return Expect.EveryDraw(Any.StringMatching(source), oracle.IsMatch);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Two contexts sharing a seed yield the same values, for every pattern and every seed.")]
+ public void TheSameSeedYieldsTheSameValues() {
+ Gen<(string Pattern, int Seed)> cases =
+ from pattern in SupportedPattern()
+ from seed in Generators.Seed()
+ select (Pattern: pattern, Seed: seed);
+
+ Prop.ForAll(cases.ToArbitrary(),
+ testCase => {
+ // A whole sequence, not a single draw: a generator that reseeded itself per value would still
+ // agree on the first one.
+ List first = Expect.Draws(Any.WithSeed(testCase.Seed).StringMatching(testCase.Pattern), 8);
+ List second = Expect.Draws(Any.WithSeed(testCase.Seed).StringMatching(testCase.Pattern), 8);
+
+ return first.SequenceEqual(second);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A bounded quantifier keeps the length inside its bounds, whatever the bounds and the form.")]
+ public void BoundedQuantifiersKeepTheLengthInsideTheirBounds() {
+ Gen<(string Atom, int Min, int Max, int Form)> cases =
+ from atom in SingleCharacterAtom()
+ from bounds in Generators.OrderedPair(Gen.Choose(0, 4))
+ from form in Gen.Choose(0, 2)
+ select (Atom: atom, Min: bounds.Min, Max: bounds.Max, Form: form);
+
+ Prop.ForAll(cases.ToArbitrary(),
+ testCase => {
+ // The three bounded forms are one rule with different bounds: '{n}' pins the count, '{n,m}'
+ // brackets it, and '?' is the fixed {0,1} case. Degenerate bounds are kept — '{0}' generating
+ // the empty string is a legitimate corner, not one to filter away.
+ (string Pattern, int Min, int Max) quantified = testCase.Form switch {
+ 0 => (testCase.Atom + "{" + Digits(testCase.Min) + "}", testCase.Min, testCase.Min),
+ 1 => (testCase.Atom + "{" + Digits(testCase.Min) + "," + Digits(testCase.Max) + "}", testCase.Min, testCase.Max),
+ _ => (testCase.Atom + "?", 0, 1)
+ };
+
+ return Expect.EveryDraw(Any.StringMatching(quantified.Pattern),
+ value => value.Length >= quantified.Min && value.Length <= quantified.Max);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "An unbounded quantifier draws its minimum plus 0 to 8 repetitions, whatever the minimum and the form.")]
+ public void UnboundedQuantifiersDrawTheMinimumPlusUpToEight() {
+ Gen<(string Atom, int Min, int Form)> cases =
+ from atom in SingleCharacterAtom()
+ from minimum in Gen.Choose(0, 4)
+ from form in Gen.Choose(0, 2)
+ select (Atom: atom, Min: minimum, Form: form);
+
+ Prop.ForAll(cases.ToArbitrary(),
+ testCase => {
+ // '*' is '{0,}' and '+' is '{1,}', so the three forms differ only in their minimum. The ceiling
+ // is the claim worth quantifying: an unbounded quantifier has to pick a spread, and the library
+ // promises the same bounded one it uses everywhere else.
+ (string Pattern, int Min) quantified = testCase.Form switch {
+ 0 => (testCase.Atom + "*", 0),
+ 1 => (testCase.Atom + "+", 1),
+ _ => (testCase.Atom + "{" + Digits(testCase.Min) + ",}", testCase.Min)
+ };
+
+ return Expect.EveryDraw(Any.StringMatching(quantified.Pattern),
+ value => value.Length >= quantified.Min
+ && value.Length <= quantified.Min + UnboundedExtra);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "Alternation reaches every declared branch and invents none, whatever the branches.")]
+ public void AlternationReachesEveryBranchAndInventsNone() {
+ Gen<(string[] Branches, int Seed)> cases =
+ from words in Gen.ArrayOf(Word(), 4)
+ from seed in Generators.Seed()
+ select (Branches: words.Distinct().ToArray(), Seed: seed);
+
+ Prop.ForAll(cases.ToArbitrary(),
+ testCase => {
+ // Branches are plain literal words, so a drawn value IS the branch that produced it. SetEquals
+ // then states both halves at once: no branch is dead, and nothing outside the declared set
+ // can come out.
+ AnyPattern generator = Any.WithSeed(testCase.Seed).StringMatching(string.Join("|", testCase.Branches));
+ HashSet seen = new(Expect.Draws(generator, BranchSampleCount));
+
+ return seen.SetEquals(testCase.Branches);
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "IgnorePatternWhitespace is refused as an argument error, whatever the pattern and the companion options.")]
+ public void IgnorePatternWhitespaceIsAnArgumentError() {
+ Gen<(string Pattern, RegexOptions Companion, int Seed)> cases =
+ from pattern in SupportedPattern()
+ from companion in Gen.Elements(CompanionOptions)
+ from seed in Generators.Seed()
+ select (Pattern: pattern, Companion: companion, Seed: seed);
+
+ Prop.ForAll(cases.ToArbitrary(),
+ testCase => {
+ // The Regex is built outside the assertion on purpose: only JustDummies' refusal is under test,
+ // never the .NET constructor's. The generated alphabets hold no whitespace and no '#', so this
+ // option cannot change whether the pattern compiles — only how JustDummies must answer.
+ Regex source = new(testCase.Pattern, RegexOptions.IgnorePatternWhitespace | testCase.Companion);
+
+ return Expect.Throws(() => Any.StringMatching(source))
+ && Expect.Throws(() => Any.WithSeed(testCase.Seed).StringMatching(source));
+ })
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A null pattern is an argument error, on both overloads and on a seeded context.")]
+ public void ANullPatternIsAnArgumentError() {
+ Prop.ForAll(Generators.Seed().ToArbitrary(),
+ seed => Expect.Throws(() => Any.StringMatching((string)null!))
+ && Expect.Throws(() => Any.StringMatching((Regex)null!))
+ && Expect.Throws(() => Any.WithSeed(seed).StringMatching((string)null!))
+ && Expect.Throws(() => Any.WithSeed(seed).StringMatching((Regex)null!)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A well-formed but unsupported construct is refused eagerly, alone and after any supported prefix.")]
+ public void UnsupportedConstructsAreRefusedAsUnsupported() {
+ Gen<(string Prefix, string Construct)> cases =
+ from prefix in SupportedPattern()
+ from construct in Gen.Elements(UnsupportedConstructs)
+ select (Prefix: prefix, Construct: construct);
+
+ Prop.ForAll(cases.ToArbitrary(),
+ // Each construct opens on '(', '[' or '\', none of which a preceding supported pattern can absorb,
+ // so appending one to an arbitrary prefix reaches the same refusal from a different parser state:
+ // the verdict must not depend on the construct sitting at position zero.
+ testCase => Expect.Throws(() => Any.StringMatching(testCase.Construct))
+ && Expect.Throws(() => Any.StringMatching(testCase.Prefix + testCase.Construct)))
+ .QuickCheckThrowOnFailure();
+ }
+
+ [Fact(DisplayName = "A malformed pattern is an argument error, never an unsupported-construct refusal.")]
+ public void MalformedPatternsAreArgumentErrors() {
+ Prop.ForAll(Gen.Elements(MalformedPatterns).ToArbitrary(),
+ // The real engine is asked first, so the property states the taxonomy rather than restating the
+ // list: a pattern .NET itself cannot compile is broken, and JustDummies must say so as an argument
+ // error. An UnsupportedRegexException here would be the wrong answer, and ThrowsMalformed rejects it.
+ pattern => !CompilesInTheRealEngine(pattern) && ThrowsMalformed(pattern))
+ .QuickCheckThrowOnFailure();
+ }
+
+}
diff --git a/JustDummies.PropertyTests/PropertyTestSupport.cs b/JustDummies.PropertyTests/PropertyTestSupport.cs
new file mode 100644
index 00000000..a378de11
--- /dev/null
+++ b/JustDummies.PropertyTests/PropertyTestSupport.cs
@@ -0,0 +1,135 @@
+#region Usings declarations
+
+using FsCheck;
+using FsCheck.Fluent;
+
+#endregion
+
+namespace JustDummies.PropertyTests;
+
+///
+/// Shared FsCheck generators for the property suite. They generate the constraints — the bounds, lengths,
+/// counts and seeds a caller declares — while JustDummies generates the value that must satisfy them. That split is
+/// the point of this project: the example-based suite pins a handful of hand-picked constraints
+/// (Between(10, 20), WithLength(12)) and can only prove the generator right for those, whereas a
+/// property quantifies over the whole constraint space and lets FsCheck shrink a failure down to its minimal
+/// counter-example.
+///
+///
+/// Drawing the constraints from FsCheck rather than from also breaks a circularity: the suite
+/// no longer uses the component under test to decide what to test it with.
+///
+internal static class Generators {
+
+ #region Statics members declarations
+
+ ///
+ /// Pairs two draws of into an ordered (min, max) tuple, so a bound pair is
+ /// always well-formed. Degenerate pairs (min == max) are deliberately kept: pinning a single value is a
+ /// legitimate — and historically fragile — corner of every interval generator.
+ ///
+ public static Gen<(T Min, T Max)> OrderedPair(Gen values, IComparer? comparer = null) {
+ IComparer order = comparer ?? Comparer.Default;
+
+ return from first in values
+ from second in values
+ select order.Compare(first, second) <= 0 ? (first, second) : (second, first);
+ }
+
+ ///
+ /// Mixes FsCheck's own draws with the edges an off-by-one hides behind. FsCheck's default numeric generator is
+ /// size-bounded and clusters around zero, so the extremes of the range would otherwise almost never be drawn —
+ /// exactly where an interval generator overflows or silently truncates.
+ ///
+ public static Gen WithEdges(Gen values, params T[] edges) {
+ return Gen.OneOf(values, Gen.Elements(edges));
+ }
+
+ /// Arbitrary s, biased towards the ends of the range.
+ public static Gen Int32() {
+ return WithEdges(ArbMap.Default.GeneratorFor(), int.MinValue, int.MinValue + 1, -1, 0, 1, int.MaxValue - 1, int.MaxValue);
+ }
+
+ /// Arbitrary s, biased towards the ends of the range.
+ public static Gen Int64() {
+ return WithEdges(ArbMap.Default.GeneratorFor(), long.MinValue, long.MinValue + 1, -1, 0, 1, long.MaxValue - 1, long.MaxValue);
+ }
+
+ /// Arbitrary finite s. NaN and the infinities are excluded: the library rejects them as argument errors.
+ public static Gen Double() {
+ return WithEdges(ArbMap.Default.GeneratorFor().Where(value => !double.IsNaN(value) && !double.IsInfinity(value)),
+ double.MinValue, -1d, 0d, 1d, double.MaxValue);
+ }
+
+ /// Arbitrary s, biased towards the ends of the range.
+ public static Gen Decimal() {
+ return WithEdges(ArbMap.Default.GeneratorFor(), decimal.MinValue, -1m, 0m, 1m, decimal.MaxValue);
+ }
+
+ /// A collection or string length: small enough to stay cheap, wide enough to cross the empty and single-element cases.
+ public static Gen Count(int max = 12) {
+ return Gen.Choose(0, max);
+ }
+
+ /// An arbitrary seed, including the values a hand-written test would never pick.
+ public static Gen Seed() {
+ return Int32();
+ }
+
+ #endregion
+
+}
+
+///
+/// Assertion helpers usable from inside an FsCheck property, where the property's verdict is a returned
+/// rather than a thrown assertion.
+///
+internal static class Expect {
+
+ #region Statics members declarations
+
+ ///
+ /// Returns true when throws an exception assignable to
+ /// ; otherwise false.
+ ///
+ public static bool Throws(Action action)
+ where TException : Exception {
+ try {
+ action();
+
+ return false;
+ } catch (TException) {
+ return true;
+ }
+ }
+
+ ///
+ /// Draws values from and returns true when every
+ /// one of them satisfies . A generator is a recipe, not a value, so one draw per
+ /// FsCheck case would leave most of its randomness untested; a handful of draws per case multiplies the
+ /// coverage without making the property expensive.
+ ///
+ public static bool EveryDraw(IAny generator, Func invariant, int count = 8) {
+ for (int i = 0; i < count; i++) {
+ if (!invariant(generator.Generate())) { return false; }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Materializes draws from , for the properties that
+ /// reason over a batch rather than over each value in isolation (reachability, distinctness, ...).
+ ///
+ public static List Draws(IAny generator, int count) {
+ List values = new(count);
+ for (int i = 0; i < count; i++) {
+ values.Add(generator.Generate());
+ }
+
+ return values;
+ }
+
+ #endregion
+
+}
diff --git a/JustDummies.PropertyTests/ScalarIntervalProperties.cs b/JustDummies.PropertyTests/ScalarIntervalProperties.cs
new file mode 100644
index 00000000..38bdd299
--- /dev/null
+++ b/JustDummies.PropertyTests/ScalarIntervalProperties.cs
@@ -0,0 +1,301 @@
+#region Usings declarations
+
+using FsCheck;
+using FsCheck.Fluent;
+
+using JetBrains.Annotations;
+
+#endregion
+
+namespace JustDummies.PropertyTests;
+
+///
+/// Property-based tests for the interval algebra of every integer width other than
+/// : , , ,
+/// , , and .
+/// They all ride the same ordinal interval engine, but each one supplies its own domain edges and its own
+/// signed-or-unsigned mapping into ordinal space — and that mapping is exactly where an off-by-one at a domain
+/// edge, or an overflow in the interval arithmetic, hides. Quantifying over the whole bound space of a width
+/// reaches those corners; the hand-picked intervals of the example-based suite cannot.
+///
+///
+/// The invariants are deliberately spread across the widths rather than repeated seven times over: each one is
+/// proven on at least one signed and one unsigned width, with the narrowest pair (sbyte/byte,
+/// where almost every bound is an edge) and the widest pair (long/ulong, where the interval
+/// arithmetic runs out of room) getting the fullest treatment.
+///
+[TestSubject(typeof(AnyInt64))]
+public sealed class ScalarIntervalProperties {
+
+ #region Statics members declarations
+
+ // One generator per width, each built on the shared Generators.WithEdges so FsCheck's size-bounded draws —
+ // which cluster around zero — are mixed with the domain edges an off-by-one hides behind. `long` needs no
+ // local generator: Generators.Int64() is already part of the shared support.
+
+ /// Arbitrary s, biased towards the ends of the range.
+ private static Gen SByte() {
+ return Generators.WithEdges(ArbMap.Default.GeneratorFor(),
+ sbyte.MinValue, sbyte.MinValue + 1, -1, 0, 1, sbyte.MaxValue - 1, sbyte.MaxValue);
+ }
+
+ /// Arbitrary