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 s, biased towards the ends of the range and the sign-bit boundary. + private static Gen Byte() { + return Generators.WithEdges(ArbMap.Default.GeneratorFor(), + byte.MinValue, 1, 127, 128, byte.MaxValue - 1, byte.MaxValue); + } + + /// Arbitrary s, biased towards the ends of the range. + private static Gen Int16() { + return Generators.WithEdges(ArbMap.Default.GeneratorFor(), + short.MinValue, short.MinValue + 1, -1, 0, 1, short.MaxValue - 1, short.MaxValue); + } + + /// Arbitrary s, biased towards the ends of the range and the sign-bit boundary. + private static Gen UInt16() { + return Generators.WithEdges(ArbMap.Default.GeneratorFor(), + ushort.MinValue, 1, 32767, 32768, ushort.MaxValue - 1, ushort.MaxValue); + } + + /// Arbitrary s, biased towards the ends of the range and the sign-bit boundary. + private static Gen UInt32() { + return Generators.WithEdges(ArbMap.Default.GeneratorFor(), + uint.MinValue, 1u, 0x8000_0000u, uint.MaxValue - 1u, uint.MaxValue); + } + + /// + /// Arbitrary s, biased towards the ends of the range and towards 2^63 — the point a + /// signed reinterpretation of the ordinal space would fold in two. + /// + private static Gen UInt64() { + return Generators.WithEdges(ArbMap.Default.GeneratorFor(), + ulong.MinValue, 1UL, 0x8000_0000_0000_0000UL, ulong.MaxValue - 1UL, ulong.MaxValue); + } + + #endregion + + [Fact(DisplayName = "SByte: Between contains — every draw falls within the declared inclusive bounds.")] + public void SByteBetweenContainsEveryDraw() { + Prop.ForAll(Generators.OrderedPair(SByte()).ToArbitrary(), + bounds => Expect.EveryDraw(Any.SByte().Between(bounds.Min, bounds.Max), + value => value >= bounds.Min && value <= bounds.Max)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "SByte: Between with equal bounds pins the value, for every value.")] + public void SByteBetweenWithEqualBoundsPins() { + Prop.ForAll(SByte().ToArbitrary(), + value => Expect.EveryDraw(Any.SByte().Between(value, value), drawn => drawn == value)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "SByte: the inclusive bounds admit their own bound, on both sides.")] + public void SByteInclusiveBoundsAdmitTheirOwnBound() { + Prop.ForAll(SByte().ToArbitrary(), + bound => Expect.EveryDraw(Any.SByte().GreaterThanOrEqualTo(bound), value => value >= bound) + && Expect.EveryDraw(Any.SByte().LessThanOrEqualTo(bound), value => value <= bound)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "SByte: GreaterThan and LessThan are strict, and conflict at the domain edge they cannot clear.")] + public void SByteStrictBoundsAreStrictAndConflictAtTheEdges() { + Prop.ForAll(SByte().ToArbitrary(), + bound => { + // There is no sbyte above sbyte.MaxValue nor below sbyte.MinValue: asking for one is a + // conflict declared at the call, never an interval that generates and then disappoints. + bool above = bound == sbyte.MaxValue + ? Expect.Throws(() => Any.SByte().GreaterThan(bound)) + : Expect.EveryDraw(Any.SByte().GreaterThan(bound), value => value > bound); + bool below = bound == sbyte.MinValue + ? Expect.Throws(() => Any.SByte().LessThan(bound)) + : Expect.EveryDraw(Any.SByte().LessThan(bound), value => value < bound); + + return above && below; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "SByte: Between never yields a value excluded by a subsequent Except.")] + public void SByteExceptRemovesTheValueFromTheInterval() { + Gen<((sbyte Min, sbyte Max) Bounds, sbyte Excluded)> cases = + from bounds in Generators.OrderedPair(SByte()) + from offset in Gen.Choose(0, bounds.Max - bounds.Min) + select (Bounds: bounds, Excluded: (sbyte)(bounds.Min + offset)); + + Prop.ForAll(cases.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.SByte().Between(testCase.Bounds.Min, testCase.Bounds.Max).Except(testCase.Excluded)); + } + + return Expect.EveryDraw(Any.SByte().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 = "Byte: Between contains — every draw falls within the declared inclusive bounds.")] + public void ByteBetweenContainsEveryDraw() { + Prop.ForAll(Generators.OrderedPair(Byte()).ToArbitrary(), + bounds => Expect.EveryDraw(Any.Byte().Between(bounds.Min, bounds.Max), + value => value >= bounds.Min && value <= bounds.Max)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Byte: Between with equal bounds pins the value, for every value.")] + public void ByteBetweenWithEqualBoundsPins() { + Prop.ForAll(Byte().ToArbitrary(), + value => Expect.EveryDraw(Any.Byte().Between(value, value), drawn => drawn == value)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Byte: the inclusive bounds admit their own bound, on both sides.")] + public void ByteInclusiveBoundsAdmitTheirOwnBound() { + Prop.ForAll(Byte().ToArbitrary(), + bound => Expect.EveryDraw(Any.Byte().GreaterThanOrEqualTo(bound), value => value >= bound) + && Expect.EveryDraw(Any.Byte().LessThanOrEqualTo(bound), value => value <= bound)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Byte: GreaterThan and LessThan are strict, and conflict at the domain edges — zero being the floor.")] + public void ByteStrictBoundsAreStrictAndConflictAtTheEdges() { + Prop.ForAll(Byte().ToArbitrary(), + bound => { + // The unsigned floor is zero, not a negative sentinel: LessThan(0) has nothing left to offer + // and must conflict rather than wrap around to byte.MaxValue. + bool above = bound == byte.MaxValue + ? Expect.Throws(() => Any.Byte().GreaterThan(bound)) + : Expect.EveryDraw(Any.Byte().GreaterThan(bound), value => value > bound); + bool below = bound == byte.MinValue + ? Expect.Throws(() => Any.Byte().LessThan(bound)) + : Expect.EveryDraw(Any.Byte().LessThan(bound), value => value < bound); + + return above && below; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Byte: Between never yields a value excluded by a subsequent Except.")] + public void ByteExceptRemovesTheValueFromTheInterval() { + Gen<((byte Min, byte Max) Bounds, byte Excluded)> cases = + from bounds in Generators.OrderedPair(Byte()) + from offset in Gen.Choose(0, bounds.Max - bounds.Min) + select (Bounds: bounds, Excluded: (byte)(bounds.Min + offset)); + + Prop.ForAll(cases.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.Byte().Between(testCase.Bounds.Min, testCase.Bounds.Max).Except(testCase.Excluded)); + } + + return Expect.EveryDraw(Any.Byte().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 = "Int16: a generator is an immutable recipe — constraining it never narrows the original.")] + public void Int16ConstrainingNeverMutatesTheOriginal() { + Prop.ForAll(Generators.OrderedPair(Int16()).ToArbitrary(), + bounds => { + AnyInt16 original = Any.Int16().Between(bounds.Min, bounds.Max); + AnyInt16 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(); + } + + [Fact(DisplayName = "UInt16: a generator is an immutable recipe — constraining it never narrows the original.")] + public void UInt16ConstrainingNeverMutatesTheOriginal() { + Prop.ForAll(Generators.OrderedPair(UInt16()).ToArbitrary(), + bounds => { + AnyUInt16 original = Any.UInt16().Between(bounds.Min, bounds.Max); + AnyUInt16 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(); + } + + [Fact(DisplayName = "UInt32: crossed bounds are rejected — as Between arguments an argument error, as two constraints a conflict.")] + public void UInt32CrossedBoundsAreRejected() { + Prop.ForAll(Generators.OrderedPair(UInt32()).ToArbitrary(), + bounds => bounds.Min == bounds.Max + // Argument validation precedes conflict checking: a swapped pair passed to a single + // call is an argument error, whereas the same emptiness spread over two calls is a + // constraint conflict. The two must not collapse into one another. + || (Expect.Throws(() => Any.UInt32().Between(bounds.Max, bounds.Min)) + && Expect.Throws( + () => Any.UInt32().GreaterThan(bounds.Max).LessThan(bounds.Min)))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "UInt32: OneOf draws only from the supplied pool, whatever the pool.")] + public void UInt32OneOfStaysWithinItsPool() { + Gen pools = Gen.NonEmptyListOf(UInt32()).Select(values => values.Distinct().ToArray()); + + Prop.ForAll(pools.ToArbitrary(), + pool => Expect.EveryDraw(Any.UInt32().OneOf(pool), value => pool.Contains(value))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Int64: Between contains — every draw falls within the bounds, across the whole 64-bit space.")] + public void Int64BetweenContainsEveryDraw() { + Prop.ForAll(Generators.OrderedPair(Generators.Int64()).ToArbitrary(), + bounds => Expect.EveryDraw(Any.Int64().Between(bounds.Min, bounds.Max), + value => value >= bounds.Min && value <= bounds.Max)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Int64: crossed bounds are rejected — as Between arguments an argument error, as two constraints a conflict.")] + public void Int64CrossedBoundsAreRejected() { + Prop.ForAll(Generators.OrderedPair(Generators.Int64()).ToArbitrary(), + bounds => bounds.Min == bounds.Max + || (Expect.Throws(() => Any.Int64().Between(bounds.Max, bounds.Min)) + && Expect.Throws( + () => Any.Int64().GreaterThan(bounds.Max).LessThan(bounds.Min)))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Int64: OneOf draws only from the supplied pool, whatever the pool.")] + public void Int64OneOfStaysWithinItsPool() { + Gen pools = Gen.NonEmptyListOf(Generators.Int64()).Select(values => values.Distinct().ToArray()); + + Prop.ForAll(pools.ToArbitrary(), + pool => Expect.EveryDraw(Any.Int64().OneOf(pool), value => pool.Contains(value))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "UInt64: Between contains — every draw falls within the bounds, up to the full unsigned range.")] + public void UInt64BetweenContainsEveryDraw() { + // The unsigned 64-bit domain is the one interval whose own size does not fit its own width: an interval + // spanning it cannot be sampled by "draw an index in [0, count)". Only quantified bounds reach that case. + Prop.ForAll(Generators.OrderedPair(UInt64()).ToArbitrary(), + bounds => Expect.EveryDraw(Any.UInt64().Between(bounds.Min, bounds.Max), + value => value >= bounds.Min && value <= bounds.Max)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "UInt64: Except never yields the excluded value, even on the unbounded full-width path.")] + public void UInt64ExceptHoldsOnTheFullWidthPath() { + // No interval is declared, so the specification still spans the whole domain and the exclusion has to be + // honoured by the full-width sampling path rather than by index arithmetic over a bounded range. + Prop.ForAll(UInt64().ToArbitrary(), + excluded => Expect.EveryDraw(Any.UInt64().Except(excluded), value => value != excluded)) + .QuickCheckThrowOnFailure(); + } + +} diff --git a/JustDummies.PropertyTests/SeedDeterminismProperties.cs b/JustDummies.PropertyTests/SeedDeterminismProperties.cs new file mode 100644 index 00000000..220cbe12 --- /dev/null +++ b/JustDummies.PropertyTests/SeedDeterminismProperties.cs @@ -0,0 +1,360 @@ +#region Usings declarations + +using FsCheck; +using FsCheck.Fluent; + +using JetBrains.Annotations; + +#endregion + +namespace JustDummies.PropertyTests; + +/// +/// Property-based tests for the seeding surface: the isolated handed out by +/// , the ambient Any.UseSeed(...) scope, and the Any.Reproducibly(...) +/// runners. The example-based suite pins three hand-picked seeds — 12345, 777, 31415 — and can therefore only +/// prove reproducibility for those three numbers; these quantify over the seed itself, including the values a +/// hand-written test would never pick (zero, int.MinValue, int.MaxValue), so a seed that fails to +/// pin a run is found and shrunk to its minimal counter-example. +/// +/// +/// +/// Reproducibility is a claim about a whole run rather than about a single draw, so every property here +/// compares a batch: a mixed sequence of scalar, collection, nullable and pattern draws joined into +/// one string. The comparison fails as soon as any one draw shifts, which makes the batch both a sharper +/// probe than a single value and the reason the distinctness property at the end can rest on entropy rather +/// than on luck. +/// +/// +/// deliberately mirrors only the scalar factories, so the collection parts of a +/// context batch go through the static combinators over a context-derived element generator. That is not a +/// workaround but part of what is under test: a collection draws from its element generator's random source, +/// never from the ambient one. +/// +/// +/// Failure diagnostics are asserted by type and, for the reported seed, by the seed being quotable +/// from the message — never by the sentence around it, whose wording is a diagnostic concern rather than a +/// seeding one. +/// +/// +[TestSubject(typeof(AnyContext))] +public sealed class SeedDeterminismProperties { + + #region Statics members declarations + + /// + /// Draws a mixed batch from and joins it into a single string: every scalar family + /// the context exposes, plus a list, a set, a nullable and a pattern. Mirrors the Batch() helper of + /// the example-based suite minus the .NET 8+ types — this file also compiles on the .NET Framework floor, + /// where Int128 and Half do not exist. + /// + private static string ContextBatch(AnyContext any) { + int full = any.Int32().Generate(); + int bounded = any.Int32().Between(1, 1000).Generate(); + string free = any.String().Generate(); + string capped = any.String().NonEmpty().WithMaxLength(50).Generate(); + string shaped = any.String().StartingWith("ORD-").WithLength(12).Generate(); + long wide = any.Int64().Generate(); + double real = any.Double().Between(0d, 1000d).Generate(); + decimal exact = any.Decimal().Between(0m, 1000m).Generate(); + bool flag = any.Boolean().Generate(); + Guid id = any.Guid().Generate(); + char letter = any.Char().Generate(); + TimeSpan span = any.TimeSpan().Generate(); + DateTime instant = any.DateTime().Generate(); + // A context carries no collection factories of its own, and does not need any: the static combinators take + // the element generator's random source, so these two collections draw from the context all the same. + List list = Any.ListOf(any.Int32().Between(0, 9)).WithCount(4).Generate(); + HashSet set = Any.SetOf(any.Int32().Between(0, 99)).WithCount(3).Generate(); + int? maybe = any.Int32().Between(0, 9).OrNull().Generate(); + string coded = any.StringMatching(@"[A-Z]{3}-\d{4}").Generate(); + + return string.Join("|", full, bounded, free, capped, shaped, + wide, real, exact, flag, id, letter, + span.Ticks, instant.Ticks, + string.Join("-", list), string.Join("-", set.OrderBy(value => value)), + maybe?.ToString() ?? "null", coded); + } + + /// + /// The same batch drawn from the static entry points, for the mechanisms that pin the ambient context + /// instead of handing out a context object — Any.UseSeed(...) and Any.Reproducibly(...). It is + /// a second method rather than one parameterized over both because and the static + /// share a surface, not a type. + /// + private static string AmbientBatch() { + int full = Any.Int32().Generate(); + int bounded = Any.Int32().Between(1, 1000).Generate(); + string free = Any.String().Generate(); + string capped = Any.String().NonEmpty().WithMaxLength(50).Generate(); + string shaped = Any.String().StartingWith("ORD-").WithLength(12).Generate(); + long wide = Any.Int64().Generate(); + double real = Any.Double().Between(0d, 1000d).Generate(); + decimal exact = Any.Decimal().Between(0m, 1000m).Generate(); + bool flag = Any.Boolean().Generate(); + Guid id = Any.Guid().Generate(); + char letter = Any.Char().Generate(); + TimeSpan span = Any.TimeSpan().Generate(); + DateTime instant = Any.DateTime().Generate(); + + List list = Any.ListOf(Any.Int32().Between(0, 9)).WithCount(4).Generate(); + HashSet set = Any.SetOf(Any.Int32().Between(0, 99)).WithCount(3).Generate(); + int? maybe = Any.Int32().Between(0, 9).OrNull().Generate(); + string coded = Any.StringMatching(@"[A-Z]{3}-\d{4}").Generate(); + + return string.Join("|", full, bounded, free, capped, shaped, + wide, real, exact, flag, id, letter, + span.Ticks, instant.Ticks, + string.Join("-", list), string.Join("-", set.OrderBy(value => value)), + maybe?.ToString() ?? "null", coded); + } + + /// + /// A blank replay snippet: the empty string, and runs of the whitespace characters Trim() removes. + /// The example-based suite pins "" and three spaces; what the guard rejects is blankness, not those + /// two spellings. + /// + private static Gen BlankSnippet() { + return from length in Gen.Choose(0, 6) + from whitespace in Gen.Elements(new[] { ' ', '\t', '\n', '\r', '\v', '\f' }) + select new string(whitespace, length); + } + + /// + /// Two seeds guaranteed to differ, drawn from the non-negative half of the seed space. The restriction is + /// deliberate and is about the BCL rather than about JustDummies: new Random(seed) derives its state + /// from the seed's absolute value, so s and -s are by design the very same generator. Quantifying + /// a distinctness claim over the whole range would therefore fail for a reason that has + /// nothing to do with the library under test. + /// + private static Gen<(int First, int Second)> DifferentSeeds() { + // Built from a base and a strictly positive delta rather than filtered for inequality, so no case is ever + // rejected and the halved bounds keep the sum inside int. + return from first in Gen.Choose(0, int.MaxValue / 2) + from delta in Gen.Choose(1, int.MaxValue / 2) + select (first, first + delta); + } + + /// + /// Returns true when throws itself + /// rather than a derived type. accepts a subclass, which would let + /// an satisfy the blank-snippet property whose whole point is that the + /// two rejections are told apart. + /// + private static bool ThrowsExactly(Action action) + where TException : Exception { + try { + action(); + + return false; + } catch (Exception exception) { + return exception.GetType() == typeof(TException); + } + } + + #endregion + + [Fact(DisplayName = "Two contexts created with the same seed replay the same batch, for every seed.")] + public void SameSeedContextsReplayTheSameBatch() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => ContextBatch(Any.WithSeed(seed)) == ContextBatch(Any.WithSeed(seed))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A context reports back the seed it was created with, for every seed.")] + public void ContextReportsItsSeed() { + // The round-trip is what makes a reported seed replayable at all: a context that silently normalized its + // seed would hand the reader a number that reproduces nothing. + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => Any.WithSeed(seed).Seed == seed) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A context is isolated: ambient draws interleaved with its own never shift its sequence.")] + public void ContextIsIsolatedFromAmbientDraws() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + AnyContext quiet = Any.WithSeed(seed); + string firstQuiet = ContextBatch(quiet); + string secondQuiet = ContextBatch(quiet); + + // The same context again, this time with ambient draws before its first batch and between + // the two. A context owns its generator, so nothing the static entry points draw may + // advance it — not even by one value, which the second batch is there to catch. + AnyContext noisy = Any.WithSeed(seed); + Any.Guid().Generate(); + string firstNoisy = ContextBatch(noisy); + Any.String().Generate(); + Any.Int32().Generate(); + string secondNoisy = ContextBatch(noisy); + + return firstNoisy == firstQuiet && secondNoisy == secondQuiet; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Reproducibly replays the same batch for the same seed, for every seed.")] + public void ReproduciblyReplaysTheSameBatch() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + // None of the four Reproducibly overloads returns a value, so the batch leaves the body + // through a captured local. + string first = string.Empty; + string second = string.Empty; + + Any.Reproducibly(seed, () => { first = AmbientBatch(); }); + Any.Reproducibly(seed, () => { second = AmbientBatch(); }); + + return second == first; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "UseSeed pins the ambient context, so the same seed replays the same batch.")] + public void UseSeedPinsTheAmbientContext() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + string first; + string second; + + using (Any.UseSeed(seed)) { first = AmbientBatch(); } + using (Any.UseSeed(seed)) { second = AmbientBatch(); } + + return second == first; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "UseSeed and Reproducibly pin the same sequence, for every seed.")] + public void UseSeedAgreesWithReproducibly() { + // The scope form exists for a caller that cannot wrap what it pins in a delegate; it must be the same + // mechanism, not a second one that happens to agree on the seeds an example picked. + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + string fromScope; + string fromRunner = string.Empty; + + using (Any.UseSeed(seed)) { fromScope = AmbientBatch(); } + Any.Reproducibly(seed, () => { fromRunner = AmbientBatch(); }); + + return fromScope == fromRunner; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "UseSeed nests: an inner scope neither consumes nor resets the outer one, for every seed pair.")] + public void UseSeedScopesNest() { + Gen<(int Outer, int Inner)> seedPairs = from outer in Generators.Seed() + from inner in Generators.Seed() + select (outer, inner); + + Prop.ForAll(seedPairs.ToArbitrary(), + pair => { + string first; + string second; + using (Any.UseSeed(pair.Outer)) { + first = AmbientBatch(); + second = AmbientBatch(); + } + + // The same outer scope, interrupted by an inner one. The inner scope draws from its own + // generator, so the outer sequence must resume exactly where it was interrupted — including + // when the two seeds happen to be equal, where the inner scope still installs a generator + // of its own rather than sharing the outer one. + string restoredFirst; + string restoredSecond; + using (Any.UseSeed(pair.Outer)) { + restoredFirst = AmbientBatch(); + using (Any.UseSeed(pair.Inner)) { AmbientBatch(); } + restoredSecond = AmbientBatch(); + } + + return restoredFirst == first && restoredSecond == second; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Disposing a UseSeed scope twice is harmless and cannot unpin a later scope.")] + public void DisposingAScopeTwiceIsHarmless() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + IDisposable stale = Any.UseSeed(seed); + stale.Dispose(); + + string expected; + using (Any.UseSeed(seed)) { expected = AmbientBatch(); } + + string actual; + using (Any.UseSeed(seed)) { + // The second dispose of an already-closed handle must do nothing at all. Were it to run + // its restore again it would reinstate its own predecessor over the scope open right + // now, and the batch below would no longer be pinned — which is what makes this a + // stronger claim than merely "it does not throw". + stale.Dispose(); + actual = AmbientBatch(); + } + + return actual == expected; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Reproducibly reports the seed and rethrows the original exception instance, for every seed.")] + public void ReproduciblyReportsTheSeedAndRethrows() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + // An empty sentinel doubles as the never-reported case: it can contain no seed either way. + string reported = string.Empty; + InvalidOperationException boom = new("boom"); + // Explicitly typed: a throw-expression lambda converts to both Action and Func, so an + // inline one would make the overload ambiguous. + Action failing = () => throw boom; + Exception? caught = null; + + try { + Any.Reproducibly(seed, failing, message => reported = message); + } catch (Exception exception) { + caught = exception; + } + + // The exception must come back as the very instance the body threw — wrapping it would cost + // the test its real message — and the seed must be quotable from the report. What sentence + // carries it is a diagnostic concern, deliberately not asserted here. + return ReferenceEquals(caught, boom) && reported.Contains(seed.ToString()); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "UseSeed rejects a null replay snippet, for every seed.")] + public void UseSeedRejectsANullSnippet() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => Expect.Throws(() => Any.UseSeed(seed, null!))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "UseSeed rejects a blank replay snippet, whatever the blank spelling.")] + public void UseSeedRejectsABlankSnippet() { + Gen<(int Seed, string Snippet)> cases = from seed in Generators.Seed() + from snippet in BlankSnippet() + select (seed, snippet); + + // Exactly ArgumentException, not merely something assignable to it: a blank snippet is not a null one, and + // the two guards must stay distinguishable. + Prop.ForAll(cases.ToArbitrary(), + testCase => ThrowsExactly(() => Any.UseSeed(testCase.Seed, testCase.Snippet))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Different seeds produce different batches — a statistical guard, not a theorem.")] + public void DifferentSeedsProduceDifferentBatches() { + // Nothing forbids two different seeds from landing on the same values, so this claim is probabilistic by + // nature. It is made robust by the batch rather than by weakening the assertion: a Guid, two full-range + // integers, three strings, a list, a set and a pattern would all have to coincide at once. What the property + // really watches for is the failure mode that would make them coincide systematically — a seed that ends up + // ignored, normalized away, or shared between contexts. + Prop.ForAll(DifferentSeeds().ToArbitrary(), + seeds => ContextBatch(Any.WithSeed(seeds.First)) != ContextBatch(Any.WithSeed(seeds.Second))) + .QuickCheckThrowOnFailure(); + } + +} diff --git a/JustDummies.PropertyTests/StringShapeProperties.cs b/JustDummies.PropertyTests/StringShapeProperties.cs new file mode 100644 index 00000000..2ecea5cb --- /dev/null +++ b/JustDummies.PropertyTests/StringShapeProperties.cs @@ -0,0 +1,360 @@ +#region Usings declarations + +using FsCheck; +using FsCheck.Fluent; + +using JetBrains.Annotations; + +#endregion + +namespace JustDummies.PropertyTests; + +/// +/// Property-based tests for 's shape algebra — lengths, anchored affixes, character +/// families, casing and exclusions. The example-based suite pins one length and one affix per invariant +/// (WithLength(10), StartingWith("ORD-")) and can only prove the layout right for those; these +/// quantify over the lengths and over the affix values themselves, so a filler budget that miscounts for +/// one length in a hundred, or a fragment check that lets one character through, is found and shrunk to its +/// minimal counter-example. +/// +/// +/// +/// Two of these properties are of a kind an example cannot express at all: the same call shape is legal or +/// illegal depending on the argument value. WithLength(n).StartingWith(prefix) holds exactly +/// when n leaves room for the prefix, and Numeric().StartingWith(prefix) holds exactly when +/// every character of the prefix is a digit. Both are written as a single property branching on that +/// relationship, so what gets tested is the boundary itself rather than a hand-picked point on either side. +/// +/// +/// Conflicts are asserted by type and at the fluent call that declares them, never on message text: +/// the messages are direction-aware — they name whichever side was declared first — so pinning them here +/// would test the wording instead of the algebra. +/// +/// +[TestSubject(typeof(AnyString))] +public sealed class StringShapeProperties { + + /// The alphabet an unconstrained generator draws from: ASCII letters and digits. + private const string DefaultAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + /// The digits alone, so an affix can be drawn from inside Numeric()'s own charset. + private const string DigitAlphabet = "0123456789"; + + /// + /// The source alphabet for a WithChars pool. It reaches beyond letters and digits — a custom pool is + /// precisely how a caller expresses an alphabet the named sets cannot — while staying free of surrogates, + /// which the pool rejects as an argument error. + /// + private const string PoolAlphabet = "ABCDEFabcdef0123456789-_.:/+*#@%&"; + + #region Statics members declarations + + /// + /// A non-empty affix of at most characters drawn from + /// . Affixes are drawn from an explicit alphabet rather than from arbitrary text + /// so that a charset conflict never fires by accident: the properties that probe the charset boundary declare + /// it deliberately, with an affix chosen for it. + /// + private static Gen Affix(string alphabet, int maxLength) { + return from characters in Gen.NonEmptyListOf(Gen.Elements(alphabet.ToCharArray())) + from length in Gen.Choose(1, maxLength) + select new string(characters.Take(length).ToArray()); + } + + /// A non-empty, duplicate-free character pool for . + private static Gen CharacterPool() { + return Gen.NonEmptyListOf(Gen.Elements(PoolAlphabet.ToCharArray())) + .Select(characters => new string(characters.Distinct().Take(12).ToArray())); + } + + /// + /// Applies one of the four character families by index — 0 Alpha, 1 Numeric, 2 + /// AlphaNumeric, 3 WithChars — so a property can quantify over the family itself instead of + /// restating the same invariant four times over. + /// + private static AnyString ApplyCharacterFamily(AnyString generator, int family, string pool) { + return family switch { + 0 => generator.Alpha(), + 1 => generator.Numeric(), + 2 => generator.AlphaNumeric(), + _ => generator.WithChars(pool) + }; + } + + /// Whether belongs to the alphabet the family selects. + private static bool AllowedByFamily(char character, int family, string pool) { + return family switch { + 0 => IsAsciiLetter(character), + 1 => IsAsciiDigit(character), + 2 => IsAsciiLetter(character) || IsAsciiDigit(character), + _ => pool.IndexOf(character) >= 0 + }; + } + + /// Applies one of the two casings, so a property can quantify over the casing itself. + private static AnyString ApplyCasing(AnyString generator, bool upper) { + return upper ? generator.UpperCase() : generator.LowerCase(); + } + + /// Anchors at one end or the other, so a property can quantify over the end. + private static AnyString ApplyAffix(AnyString generator, bool asSuffix, string affix) { + return asSuffix ? generator.EndingWith(affix) : generator.StartingWith(affix); + } + + // char.IsAsciiLetter/IsAsciiDigit are .NET 7+, and this suite also runs on the netstandard2.0 asset from the + // net472 floor — so the two classifications the library itself uses are restated here. + + private static bool IsAsciiLetter(char character) { + return character is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + } + + private static bool IsAsciiDigit(char character) { + return character is >= '0' and <= '9'; + } + + #endregion + + [Fact(DisplayName = "WithLength fixes the length exactly, for every length.")] + public void WithLengthFixesTheLengthExactly() { + Prop.ForAll(Generators.Count(40).ToArbitrary(), + length => Expect.EveryDraw(Any.String().WithLength(length), value => value.Length == length)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithMinLength is an inclusive floor: every draw is at least that long.")] + public void WithMinLengthIsAnInclusiveFloor() { + Prop.ForAll(Generators.Count(40).ToArbitrary(), + minimum => Expect.EveryDraw(Any.String().WithMinLength(minimum), value => value.Length >= minimum)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithMaxLength is an inclusive ceiling: every draw is at most that long.")] + public void WithMaxLengthIsAnInclusiveCeiling() { + Prop.ForAll(Generators.Count(40).ToArbitrary(), + maximum => Expect.EveryDraw(Any.String().WithMaxLength(maximum), value => value.Length <= maximum)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithLengthBetween bounds the length inclusively, for every bound pair.")] + public void WithLengthBetweenIsAnInclusiveRange() { + Prop.ForAll(Generators.OrderedPair(Generators.Count(40)).ToArbitrary(), + bounds => Expect.EveryDraw(Any.String().WithLengthBetween(bounds.Min, bounds.Max), + value => value.Length >= bounds.Min && value.Length <= bounds.Max)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Crossed WithLengthBetween arguments are an argument error, never a silent swap.")] + public void CrossedLengthBoundsAreAnArgumentError() { + Prop.ForAll(Generators.OrderedPair(Generators.Count(40)).ToArbitrary(), + bounds => bounds.Min == bounds.Max + || Expect.Throws(() => Any.String().WithLengthBetween(bounds.Max, bounds.Min))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "NonEmpty never yields the empty string, under every maximum length that leaves room.")] + public void NonEmptyNeverYieldsTheEmptyString() { + Prop.ForAll(Generators.Count(40).ToArbitrary(), + maximum => { + // NonEmpty is a minimum of one character, so capping the length at zero leaves nothing to + // draw: the pair is rejected at declaration, not at generation. + if (maximum == 0) { + return Expect.Throws(() => Any.String().NonEmpty().WithMaxLength(0)); + } + + return Expect.EveryDraw(Any.String().NonEmpty().WithMaxLength(maximum), + value => value.Length >= 1 && value.Length <= maximum); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "StartingWith anchors the prefix, whatever the prefix.")] + public void StartingWithAnchorsThePrefix() { + Prop.ForAll(Affix(DefaultAlphabet, 8).ToArbitrary(), + prefix => Expect.EveryDraw(Any.String().StartingWith(prefix), + value => value.StartsWith(prefix, StringComparison.Ordinal))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "EndingWith anchors the suffix, whatever the suffix.")] + public void EndingWithAnchorsTheSuffix() { + Prop.ForAll(Affix(DefaultAlphabet, 8).ToArbitrary(), + suffix => Expect.EveryDraw(Any.String().EndingWith(suffix), + value => value.EndsWith(suffix, StringComparison.Ordinal))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Containing embeds the value, whatever the value.")] + public void ContainingEmbedsTheValue() { + Prop.ForAll(Affix(DefaultAlphabet, 8).ToArbitrary(), + fragment => Expect.EveryDraw(Any.String().Containing(fragment), + // string.Contains(string, StringComparison) is not on the netstandard2.0 + // floor; IndexOf carries the same ordinal comparison. + value => value.IndexOf(fragment, StringComparison.Ordinal) >= 0)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Every character family draws only from its own alphabet, at every length.")] + public void CharacterFamiliesDrawOnlyFromTheirOwnAlphabet() { + Gen<(int Family, string Pool, int Length)> cases = + from family in Gen.Choose(0, 3) + from pool in CharacterPool() + from length in Generators.Count(20) + select (Family: family, Pool: pool, Length: length); + + Prop.ForAll(cases.ToArbitrary(), + testCase => Expect.EveryDraw(ApplyCharacterFamily(Any.String(), testCase.Family, testCase.Pool).WithLength(testCase.Length), + value => value.All(character => AllowedByFamily(character, testCase.Family, testCase.Pool)))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A casing constrains every cased character, at every length.")] + public void ACasingConstrainsEveryCasedCharacter() { + Gen<(bool Upper, int Length)> cases = + from upper in Gen.Elements(new[] { false, true }) + from length in Generators.Count(20) + select (Upper: upper, Length: length); + + Prop.ForAll(cases.ToArbitrary(), + // A casing constrains the letters only: digits stay drawable under either of them. + testCase => Expect.EveryDraw(ApplyCasing(Any.String(), testCase.Upper).WithLength(testCase.Length), + value => value.All(character => testCase.Upper + ? !(character is >= 'a' and <= 'z') + : !(character is >= 'A' and <= 'Z')))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithLength and StartingWith hold together exactly when the length leaves room for the prefix.")] + public void ExactLengthAndPrefixHoldTogetherExactlyWhenThereIsRoom() { + Gen<(string Prefix, int Length)> cases = + from prefix in Affix(DefaultAlphabet, 8) + from length in Generators.Count(12) + select (Prefix: prefix, Length: length); + + Prop.ForAll(cases.ToArbitrary(), + testCase => { + // The boundary an example test can only sample: one character below it the pair is a + // declaration-time conflict, at it and above it the pair is generable — same call shape, + // legality decided by the argument values. + if (testCase.Length < testCase.Prefix.Length) { + return Expect.Throws( + () => Any.String().WithLength(testCase.Length).StartingWith(testCase.Prefix)); + } + + return Expect.EveryDraw(Any.String().WithLength(testCase.Length).StartingWith(testCase.Prefix), + value => value.Length == testCase.Length + && value.StartsWith(testCase.Prefix, StringComparison.Ordinal)); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Numeric accepts a prefix exactly when every one of its characters is a digit.")] + public void NumericAcceptsAPrefixExactlyWhenEveryCharacterIsADigit() { + // Half the prefixes come from the digits themselves and half from the full default alphabet, so both sides of + // the boundary are reached often — Numeric().StartingWith("123") is valid where + // Numeric().StartingWith("ORD-") conflicts, on the argument value alone. + Gen prefixes = Gen.OneOf(Affix(DigitAlphabet, 4), Affix(DefaultAlphabet, 4)); + + Prop.ForAll(prefixes.ToArbitrary(), + prefix => prefix.All(IsAsciiDigit) + ? Expect.EveryDraw(Any.String().Numeric().StartingWith(prefix), + value => value.StartsWith(prefix, StringComparison.Ordinal) && value.All(IsAsciiDigit)) + : Expect.Throws(() => Any.String().Numeric().StartingWith(prefix))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Exclusions accumulate and never yield an excluded value, while preserving the declared shape.")] + public void ExclusionsNeverYieldAnExcludedValue() { + Prop.ForAll(Gen.Choose(3, 8).ToArbitrary(), + length => { + // The excluded values are drawn from the very generator they are then excluded from, so the + // exclusion is never vacuous. Three letters already allow 52^3 candidates, so removing a + // handful leaves the shape amply satisfiable: the redraw budget is not what is under test. + AnyString shaped = Any.String().Alpha().WithLength(length); + string[] excluded = Expect.Draws(shaped, 3).Distinct().ToArray(); + string banned = shaped.Generate(); + AnyString narrowed = shaped.Except(excluded).DifferentFrom(banned); + + return Expect.EveryDraw(narrowed, + value => value.Length == length + && value.All(IsAsciiLetter) + && !excluded.Contains(value) + && value != banned); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A second character family conflicts, whichever two are combined.")] + public void ASecondCharacterFamilyConflicts() { + Gen<(int First, int Second, string Pool)> cases = + from first in Gen.Choose(0, 3) + from second in Gen.Choose(0, 3) + from pool in CharacterPool() + select (First: first, Second: second, Pool: pool); + + Prop.ForAll(cases.ToArbitrary(), + // The pair may name the same family twice: the slot is declared once, so repeating it conflicts too. + testCase => Expect.Throws( + () => ApplyCharacterFamily(ApplyCharacterFamily(Any.String(), testCase.First, testCase.Pool), + testCase.Second, testCase.Pool))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A second casing conflicts, whichever two are combined.")] + public void ASecondCasingConflicts() { + Gen<(bool First, bool Second)> cases = + from first in Gen.Elements(new[] { false, true }) + from second in Gen.Elements(new[] { false, true }) + select (First: first, Second: second); + + Prop.ForAll(cases.ToArbitrary(), + testCase => Expect.Throws( + () => ApplyCasing(ApplyCasing(Any.String(), testCase.First), testCase.Second))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A second exact length conflicts, even when it repeats the first.")] + public void ASecondExactLengthConflicts() { + Gen<(int First, int Second)> cases = + from first in Generators.Count(40) + from second in Generators.Count(40) + select (First: first, Second: second); + + Prop.ForAll(cases.ToArbitrary(), + testCase => Expect.Throws( + () => Any.String().WithLength(testCase.First).WithLength(testCase.Second))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A second prefix or a second suffix conflicts, whatever the two values.")] + public void ASecondPrefixOrSuffixConflicts() { + Gen<(bool AsSuffix, string First, string Second)> cases = + from asSuffix in Gen.Elements(new[] { false, true }) + from first in Affix(DefaultAlphabet, 6) + from second in Affix(DefaultAlphabet, 6) + select (AsSuffix: asSuffix, First: first, Second: second); + + Prop.ForAll(cases.ToArbitrary(), + testCase => Expect.Throws( + () => ApplyAffix(ApplyAffix(Any.String(), testCase.AsSuffix, testCase.First), + testCase.AsSuffix, testCase.Second))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "OneOf on an already constrained generator conflicts: it stays a terminal specification.")] + public void OneOfOnAConstrainedGeneratorConflicts() { + Gen pools = Gen.NonEmptyListOf(Affix(DefaultAlphabet, 6)).Select(values => values.Distinct().ToArray()); + + Gen<(int Length, string[] Pool)> cases = + from length in Generators.Count(20) + from pool in pools + select (Length: length, Pool: pool); + + Prop.ForAll(cases.ToArbitrary(), + // WithLength constrains the generator for every length, zero included, so the terminal set can + // never be grafted onto it — whatever the pool. + testCase => Expect.Throws( + () => Any.String().WithLength(testCase.Length).OneOf(testCase.Pool))) + .QuickCheckThrowOnFailure(); + } + +} diff --git a/JustDummies.PropertyTests/TemporalProperties.cs b/JustDummies.PropertyTests/TemporalProperties.cs new file mode 100644 index 00000000..3b0f644a --- /dev/null +++ b/JustDummies.PropertyTests/TemporalProperties.cs @@ -0,0 +1,480 @@ +#region Usings declarations + +using FsCheck; +using FsCheck.Fluent; + +using JetBrains.Annotations; + +#endregion + +namespace JustDummies.PropertyTests; + +/// +/// Property-based tests for the three temporal generators — , +/// and , including its offset dimension. Where the +/// example-based suite pins one anchor instant and a handful of hand-picked windows, these draw the instants, +/// the durations and the offsets themselves, over the whole ten-thousand-year domain and the whole ±14:00 +/// offset range, so a bound that overflows at the edge of the domain, or an offset that quietly pins itself to +/// one end of its range, is found and shrunk to its minimal counter-example. +/// +/// +/// Two traps shape almost every property here. The naming differs by type — and +/// say After/Before while says +/// GreaterThan/LessThan — and legality is value-dependent: Positive() on an interval +/// that lies below zero, After at the very top of the domain, or a WithOffset that no longer fits +/// the instant window already declared are conflicts rather than narrowings. The properties therefore decide +/// the expectation from the drawn value instead of assuming the call shape settles it. +/// +/// Instants are built from a drawn tick count rather than from FsCheck's own +/// arbitrary, so this file owns its domain and the distance it keeps from the edges that overflow. Bounds +/// are compared the way the library compares them — by ticks for (kind ignored) and +/// by for (rendering ignored). +/// +/// +[TestSubject(typeof(AnyDateTimeOffset))] +public sealed class TemporalProperties { + + #region Statics members declarations + + /// admits an offset in whole minutes within ±14:00; both offset constraints mirror that domain. + private const int MaxOffsetMinutes = 14 * 60; + + /// The narrowest offset range the variation property accepts, so the range always holds enough offsets for "it varies" to mean something. + private const int SpreadMinutes = 60; + + /// Draws taken when a property reasons over a batch rather than over each value in isolation. + private const int VariationDraws = 24; + + /// The widest window below the top of the domain the offset-tightening property opens — deliberately wider than ±14:00, so both sides of the legality line are drawn. + private const int CeilingWindowMinutes = 15 * 60; + + private static readonly DateTime AnchorInstant = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTimeOffset AnchorMoment = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + /// The number of ticks in the instant domain, shared by and — their maxima carry the same tick count. + private static readonly ulong DomainTicks = (ulong)DateTime.MaxValue.Ticks + 1UL; + + /// + /// A 64-bit word drawn over its whole range. FsCheck's own numeric generators are size-bounded and rarely + /// leave a hundred of zero, which for a tick count would mean "always within a microsecond of year one"; the + /// word is therefore assembled from three narrow draws, each spanning far less than a 32-bit range so the + /// span itself never has to be counted in 64 bits. + /// + private static Gen Bits64() { + return from high in Gen.Choose(-(1 << 21), (1 << 21) - 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 a sign-extended operand (CS0675) to carry `high`'s sign into bit 63. + select ((long)high << 42) + ((long)middle << 21) + low; + } + + /// + /// Arbitrary instants over the whole domain, its edges included and its + /// drawn rather than fixed: constraints compare by + /// and ignore the kind of the bounds they are handed, exactly as 's own operators do. + /// + private static Gen Instants() { + Gen anywhere = from bits in Bits64() + from kind in Gen.Choose(0, 2) + select new DateTime((long)((ulong)bits % DomainTicks), (DateTimeKind)kind); + + return Generators.WithEdges(anywhere, DateTime.MinValue, DateTime.MinValue.AddTicks(1), AnchorInstant, + DateTime.MaxValue.AddTicks(-1), DateTime.MaxValue); + } + + /// Arbitrary durations over the whole domain, biased towards zero and towards the edges an off-by-one hides behind. + private static Gen Durations() { + Gen anywhere = Bits64().Select(ticks => TimeSpan.FromTicks(ticks)); + + return Generators.WithEdges(anywhere, TimeSpan.MinValue, TimeSpan.FromTicks(long.MinValue + 1), TimeSpan.FromTicks(-1), + TimeSpan.Zero, TimeSpan.FromTicks(1), TimeSpan.FromTicks(long.MaxValue - 1), TimeSpan.MaxValue); + } + + /// + /// Arbitrary instants over the whole domain, expressed in UTC: a bound built at + /// the edge of the domain with a non-zero offset would overflow on construction, and the constraints compare + /// by anyway, so the offset of a bound carries no information. + /// + private static Gen Moments() { + Gen anywhere = Bits64().Select(bits => new DateTimeOffset((long)((ulong)bits % DomainTicks), TimeSpan.Zero)); + + return Generators.WithEdges(anywhere, DateTimeOffset.MinValue, DateTimeOffset.MinValue.AddTicks(1), AnchorMoment, + DateTimeOffset.MaxValue.AddTicks(-1), DateTimeOffset.MaxValue); + } + + /// A legal offset in whole minutes: anywhere within ±14:00, biased towards the ends of the range and towards UTC. + private static Gen OffsetMinutes() { + return Generators.WithEdges(Gen.Choose(-MaxOffsetMinutes, MaxOffsetMinutes), -MaxOffsetMinutes, -1, 0, 1, MaxOffsetMinutes); + } + + /// + /// Two legal whole-minute offsets that are strictly apart. This is the shape every "declared once" conflict + /// needs: re-declaring the same offset range is idempotent by design, so a property quantifying over + /// two independent draws would expect a conflict that never comes. + /// + private static Gen<(int Low, int High)> DistinctOffsetMinutes() { + return from low in Gen.Choose(-MaxOffsetMinutes, MaxOffsetMinutes - 1) + from high in Gen.Choose(low + 1, MaxOffsetMinutes) + select (Low: low, High: high); + } + + /// Turns a whole number of minutes into an offset without going through the double-based factories, so no rounding can creep between the draw and the call. + private static TimeSpan Minutes(int minutes) { + return TimeSpan.FromTicks(minutes * TimeSpan.TicksPerMinute); + } + + /// + /// Returns true when throws an — that + /// exact type — naming . The exactness is the point: + /// derives from , so a mere + /// assignability check could not tell a malformed offset from an out-of-range one, and the offset dimension + /// distinguishes the two deliberately. + /// + private static bool ThrowsArgumentExceptionNaming(Action action, string parameterName) { + try { + action(); + + return false; + } catch (ArgumentException exception) { + return exception.GetType() == typeof(ArgumentException) && exception.ParamName == parameterName; + } + } + + #endregion + + [Fact(DisplayName = "DateTime: Between contains — every draw falls within the declared inclusive instants.")] + public void DateTimeBetweenContainsEveryDraw() { + Prop.ForAll(Generators.OrderedPair(Instants()).ToArbitrary(), + bounds => Expect.EveryDraw(Any.DateTime().Between(bounds.Min, bounds.Max), + value => value >= bounds.Min && value <= bounds.Max)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTime: Between with equal instants pins the value, for every instant.")] + public void DateTimeBetweenWithEqualBoundsPins() { + Prop.ForAll(Instants().ToArbitrary(), + instant => Expect.EveryDraw(Any.DateTime().Between(instant, instant), drawn => drawn.Ticks == instant.Ticks)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTime: After and Before are exclusive, and conflict at the edge of the domain.")] + public void DateTimeAfterAndBeforeAreExclusive() { + Prop.ForAll(Instants().ToArbitrary(), + instant => { + // No instant lies after DateTime.MaxValue, and none before DateTime.MinValue: there the + // exclusive bound empties the domain, and the library owes a conflict at the fluent call + // rather than a failure at Generate(). + bool after = instant == DateTime.MaxValue + ? Expect.Throws(() => Any.DateTime().After(instant)) + : Expect.EveryDraw(Any.DateTime().After(instant), value => value > instant); + bool before = instant == DateTime.MinValue + ? Expect.Throws(() => Any.DateTime().Before(instant)) + : Expect.EveryDraw(Any.DateTime().Before(instant), value => value < instant); + + return after && before; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTime: AfterOrEqualTo and BeforeOrEqualTo are inclusive, right up to the edge of the domain.")] + public void DateTimeInclusiveBoundsAreInclusive() { + Prop.ForAll(Instants().ToArbitrary(), + instant => Expect.EveryDraw(Any.DateTime().AfterOrEqualTo(instant), value => value >= instant) + && Expect.EveryDraw(Any.DateTime().BeforeOrEqualTo(instant), value => value <= instant)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTime: every generated value carries Utc kind, whatever kind the bounds carry.")] + public void DateTimeGeneratedValuesCarryUtcKind() { + Prop.ForAll(Instants().ToArbitrary(), + instant => Expect.EveryDraw(Any.DateTime(), value => value.Kind == DateTimeKind.Utc) + && Expect.EveryDraw(Any.DateTime().AfterOrEqualTo(instant), value => value.Kind == DateTimeKind.Utc) + && Expect.EveryDraw(Any.DateTime().BeforeOrEqualTo(instant), value => value.Kind == DateTimeKind.Utc)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTime: crossed Between instants are an argument error naming the start, never a silent swap.")] + public void DateTimeCrossedBoundsAreAnArgumentError() { + Prop.ForAll(Generators.OrderedPair(Instants()).ToArbitrary(), + bounds => bounds.Min == bounds.Max + || ThrowsArgumentExceptionNaming(() => Any.DateTime().Between(bounds.Max, bounds.Min), "start")) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "TimeSpan: Between contains — every draw falls within the declared inclusive durations.")] + public void TimeSpanBetweenContainsEveryDraw() { + Prop.ForAll(Generators.OrderedPair(Durations()).ToArbitrary(), + bounds => Expect.EveryDraw(Any.TimeSpan().Between(bounds.Min, bounds.Max), + value => value >= bounds.Min && value <= bounds.Max)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "TimeSpan: GreaterThan and LessThan are exclusive, and conflict at the edge of the domain.")] + public void TimeSpanExclusiveBoundsAreExclusive() { + Prop.ForAll(Durations().ToArbitrary(), + duration => { + // The duration surface names its bounds GreaterThan/LessThan where the instant surface says + // After/Before; the invariant underneath is the same one. + bool greater = duration == TimeSpan.MaxValue + ? Expect.Throws(() => Any.TimeSpan().GreaterThan(duration)) + : Expect.EveryDraw(Any.TimeSpan().GreaterThan(duration), value => value > duration); + bool less = duration == TimeSpan.MinValue + ? Expect.Throws(() => Any.TimeSpan().LessThan(duration)) + : Expect.EveryDraw(Any.TimeSpan().LessThan(duration), value => value < duration); + + return greater && less; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "TimeSpan: GreaterThanOrEqualTo and LessThanOrEqualTo are inclusive, right up to the edge of the domain.")] + public void TimeSpanInclusiveBoundsAreInclusive() { + Prop.ForAll(Durations().ToArbitrary(), + duration => Expect.EveryDraw(Any.TimeSpan().GreaterThanOrEqualTo(duration), value => value >= duration) + && Expect.EveryDraw(Any.TimeSpan().LessThanOrEqualTo(duration), value => value <= duration)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "TimeSpan: Positive and Negative are strict about zero, and conflict with an interval lying on the wrong side of it.")] + public void TimeSpanPositiveAndNegativeAreStrictAboutZero() { + Prop.ForAll(Generators.OrderedPair(Durations()).ToArbitrary(), + bounds => { + AnyTimeSpan interval = Any.TimeSpan().Between(bounds.Min, bounds.Max); + + // Value-dependent legality: the very same call narrows an interval that still reaches past + // zero and empties one that does not, so the expectation is read off the drawn bounds. An + // interval touching zero from one side only is exactly the corner an example would miss. + bool positive = bounds.Max > TimeSpan.Zero + ? Expect.EveryDraw(interval.Positive(), + value => value > TimeSpan.Zero && value >= bounds.Min && value <= bounds.Max) + : Expect.Throws(() => interval.Positive()); + bool negative = bounds.Min < TimeSpan.Zero + ? Expect.EveryDraw(interval.Negative(), + value => value < TimeSpan.Zero && value >= bounds.Min && value <= bounds.Max) + : Expect.Throws(() => interval.Negative()); + + return positive && negative; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "TimeSpan: Zero pins any interval that holds zero, and conflicts with every interval that does not.")] + public void TimeSpanZeroPinsTheIntervalsThatHoldIt() { + Prop.ForAll(Generators.OrderedPair(Durations()).ToArbitrary(), + bounds => { + AnyTimeSpan interval = Any.TimeSpan().Between(bounds.Min, bounds.Max); + + if (bounds.Min <= TimeSpan.Zero && bounds.Max >= TimeSpan.Zero) { + return Expect.EveryDraw(interval.Zero(), value => value == TimeSpan.Zero); + } + + return Expect.Throws(() => interval.Zero()); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "TimeSpan: NonZero removes zero from any interval, and empties the one interval holding nothing else.")] + public void TimeSpanNonZeroExcludesZero() { + Prop.ForAll(Generators.OrderedPair(Durations()).ToArbitrary(), + bounds => { + AnyTimeSpan interval = Any.TimeSpan().Between(bounds.Min, bounds.Max); + + // Excluding zero from the interval pinned to zero leaves nothing to draw: that is a conflict + // at the fluent call, the duration counterpart of excluding the single value of a pinned + // integer interval. + if (bounds.Min == TimeSpan.Zero && bounds.Max == TimeSpan.Zero) { + return Expect.Throws(() => interval.NonZero()); + } + + return Expect.EveryDraw(interval.NonZero(), + value => value != TimeSpan.Zero && value >= bounds.Min && value <= bounds.Max); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "TimeSpan: crossed Between durations are an argument error naming the minimum, never a silent swap.")] + public void TimeSpanCrossedBoundsAreAnArgumentError() { + Prop.ForAll(Generators.OrderedPair(Durations()).ToArbitrary(), + bounds => bounds.Min == bounds.Max + || ThrowsArgumentExceptionNaming(() => Any.TimeSpan().Between(bounds.Max, bounds.Min), "minimum")) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTimeOffset: Between contains — every draw falls within the declared inclusive instants.")] + public void DateTimeOffsetBetweenContainsEveryDraw() { + Prop.ForAll(Generators.OrderedPair(Moments()).ToArbitrary(), + bounds => Expect.EveryDraw(Any.DateTimeOffset().Between(bounds.Min, bounds.Max), + value => value.UtcTicks >= bounds.Min.UtcTicks && value.UtcTicks <= bounds.Max.UtcTicks)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTimeOffset: After and Before are exclusive, and conflict at the edge of the domain.")] + public void DateTimeOffsetAfterAndBeforeAreExclusive() { + Prop.ForAll(Moments().ToArbitrary(), + instant => { + bool after = instant == DateTimeOffset.MaxValue + ? Expect.Throws(() => Any.DateTimeOffset().After(instant)) + : Expect.EveryDraw(Any.DateTimeOffset().After(instant), value => value.UtcTicks > instant.UtcTicks); + bool before = instant == DateTimeOffset.MinValue + ? Expect.Throws(() => Any.DateTimeOffset().Before(instant)) + : Expect.EveryDraw(Any.DateTimeOffset().Before(instant), value => value.UtcTicks < instant.UtcTicks); + + return after && before; + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTimeOffset: AfterOrEqualTo and BeforeOrEqualTo are inclusive, right up to the edge of the domain.")] + public void DateTimeOffsetInclusiveBoundsAreInclusive() { + Prop.ForAll(Moments().ToArbitrary(), + instant => Expect.EveryDraw(Any.DateTimeOffset().AfterOrEqualTo(instant), value => value.UtcTicks >= instant.UtcTicks) + && Expect.EveryDraw(Any.DateTimeOffset().BeforeOrEqualTo(instant), value => value.UtcTicks <= instant.UtcTicks)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTimeOffset: with no offset constraint every draw carries the UTC offset, whatever the instant window.")] + public void DateTimeOffsetDefaultsToTheUtcOffset() { + Prop.ForAll(Generators.OrderedPair(Moments()).ToArbitrary(), + bounds => Expect.EveryDraw(Any.DateTimeOffset(), value => value.Offset == TimeSpan.Zero) + && Expect.EveryDraw(Any.DateTimeOffset().Between(bounds.Min, bounds.Max), value => value.Offset == TimeSpan.Zero)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "DateTimeOffset: crossed Between instants are an argument error naming the start, never a silent swap.")] + public void DateTimeOffsetCrossedBoundsAreAnArgumentError() { + Prop.ForAll(Generators.OrderedPair(Moments()).ToArbitrary(), + bounds => bounds.Min == bounds.Max + || ThrowsArgumentExceptionNaming(() => Any.DateTimeOffset().Between(bounds.Max, bounds.Min), "start")) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithOffset: every draw carries exactly the pinned offset, for every legal offset.")] + public void WithOffsetPinsTheOffset() { + Prop.ForAll(OffsetMinutes().ToArbitrary(), + minutes => { + TimeSpan offset = Minutes(minutes); + + // Reaching the assertion at all is half the property: the local ticks are the UTC ticks + // shifted by the offset, so without the instant range the library tightens on declaration, + // the extreme offsets would overflow inside Generate() long before the offset is compared. + return Expect.EveryDraw(Any.DateTimeOffset().WithOffset(offset), + value => value.Offset == offset + && value.UtcTicks + offset.Ticks >= DateTime.MinValue.Ticks + && value.UtcTicks + offset.Ticks <= DateTime.MaxValue.Ticks); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithOffset: an instant window with no room left for the offset conflicts; one with room keeps both.")] + public void WithOffsetTightensTheInstantWindow() { + Prop.ForAll((from floorMinutes in Gen.Choose(1, CeilingWindowMinutes) + from offsetMinutes in OffsetMinutes() + select (floorMinutes, offsetMinutes)).ToArbitrary(), + testCase => { + DateTimeOffset floor = DateTimeOffset.MaxValue.AddTicks(-testCase.floorMinutes * TimeSpan.TicksPerMinute); + TimeSpan offset = Minutes(testCase.offsetMinutes); + AnyDateTimeOffset windowed = Any.DateTimeOffset().After(floor); + + // Value-dependent legality again: the last hours of the domain can host a +02:00 offset but + // not a +14:00 one, and host every negative offset whatever the window — the window must be + // wider than the shift the offset applies to the local ticks. + if (testCase.floorMinutes <= testCase.offsetMinutes) { + return Expect.Throws(() => windowed.WithOffset(offset)); + } + + return Expect.EveryDraw(windowed.WithOffset(offset), + value => value.Offset == offset && value.UtcTicks > floor.UtcTicks); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithOffsetBetween: every draw's offset lies within the inclusive range, in whole minutes.")] + public void WithOffsetBetweenStaysWithinItsRange() { + Prop.ForAll(Generators.OrderedPair(OffsetMinutes()).ToArbitrary(), + bounds => { + TimeSpan minimum = Minutes(bounds.Min); + TimeSpan maximum = Minutes(bounds.Max); + + // The degenerate range is kept: a range collapsed onto one offset must pin it, not reject it. + return Expect.EveryDraw(Any.DateTimeOffset().WithOffsetBetween(minimum, maximum), + value => value.Offset >= minimum + && value.Offset <= maximum + && value.Offset.Ticks % TimeSpan.TicksPerMinute == 0); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithOffsetBetween: over enough draws the offset really varies — it is drawn, not pinned to an end of the range.")] + public void WithOffsetBetweenVariesTheOffset() { + Prop.ForAll((from low in Gen.Choose(-MaxOffsetMinutes, MaxOffsetMinutes - SpreadMinutes) + from high in Gen.Choose(low + SpreadMinutes, MaxOffsetMinutes) + select (low, high)).ToArbitrary(), + bounds => { + TimeSpan minimum = Minutes(bounds.low); + TimeSpan maximum = Minutes(bounds.high); + + // The range is drawn at least an hour wide, so it always offers more than sixty offsets: a + // generator that quietly pinned the offset to one end — the failure a single-draw assertion + // cannot see, since one end of the range satisfies the bounds perfectly — surfaces here. + List draws = Expect.Draws(Any.DateTimeOffset().WithOffsetBetween(minimum, maximum), VariationDraws); + + return draws.Select(value => value.Offset).Distinct().Count() > 1 + && draws.All(value => value.Offset >= minimum && value.Offset <= maximum); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Offsets: an offset carrying a sub-minute remainder is an argument error, wherever the remainder falls.")] + public void SubMinuteOffsetsAreAnArgumentError() { + Prop.ForAll((from minutes in Gen.Choose(-(MaxOffsetMinutes - 1), MaxOffsetMinutes - 1) + from remainder in Gen.Choose(1, (int)TimeSpan.TicksPerMinute - 1) + select TimeSpan.FromTicks(minutes * TimeSpan.TicksPerMinute + remainder)).ToArbitrary(), + offset => + // The drawn offsets stay inside ±14:00, so it is the whole-minute rule that fires and not + // the range rule — argument validation runs in that order, and the two are told apart by + // the exact exception type. + ThrowsArgumentExceptionNaming(() => Any.DateTimeOffset().WithOffset(offset), "offset") + && ThrowsArgumentExceptionNaming(() => Any.DateTimeOffset().WithOffsetBetween(offset, TimeSpan.Zero), "minimum") + && ThrowsArgumentExceptionNaming(() => Any.DateTimeOffset().WithOffsetBetween(TimeSpan.Zero, offset), "maximum")) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Offsets: a whole-minute offset beyond ±14:00 is out of range, however far beyond it lands.")] + public void OffsetsBeyondFourteenHoursAreOutOfRange() { + Prop.ForAll((from magnitude in Gen.Choose(MaxOffsetMinutes + 1, 10 * MaxOffsetMinutes) + from mirrored in Gen.Choose(0, 1) + select Minutes(mirrored == 0 ? magnitude : -magnitude)).ToArbitrary(), + // Whole minutes by construction, so the whole-minute rule passes and the range rule is the one + // under test. + offset => Expect.Throws(() => Any.DateTimeOffset().WithOffset(offset)) + && Expect.Throws(() => Any.DateTimeOffset().WithOffsetBetween(offset, offset))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithOffsetBetween: a crossed offset range is an argument error naming the minimum, never a silent swap.")] + public void CrossedOffsetRangeIsAnArgumentError() { + Prop.ForAll(DistinctOffsetMinutes().ToArbitrary(), + bounds => ThrowsArgumentExceptionNaming( + () => Any.DateTimeOffset().WithOffsetBetween(Minutes(bounds.High), Minutes(bounds.Low)), "minimum")) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Offsets: the offset dimension is declared once — a second, different offset constraint conflicts whichever form it takes.")] + public void TheOffsetDimensionIsDeclaredOnce() { + Prop.ForAll(DistinctOffsetMinutes().ToArbitrary(), + bounds => { + TimeSpan low = Minutes(bounds.Low); + TimeSpan high = Minutes(bounds.High); + + return Expect.Throws(() => Any.DateTimeOffset().WithOffset(low).WithOffset(high)) + && Expect.Throws(() => Any.DateTimeOffset().WithOffset(low).WithOffsetBetween(low, high)) + && Expect.Throws(() => Any.DateTimeOffset().WithOffsetBetween(low, high).WithOffset(high)) + // Re-declaring the very same range is idempotent, not a conflict: the dimension is + // declared once, which is not the same thing as called once. + && Expect.EveryDraw(Any.DateTimeOffset().WithOffset(low).WithOffset(low), value => value.Offset == low); + }) + .QuickCheckThrowOnFailure(); + } + +} diff --git a/JustDummies.PropertyTests/UriProperties.cs b/JustDummies.PropertyTests/UriProperties.cs new file mode 100644 index 00000000..8b158464 --- /dev/null +++ b/JustDummies.PropertyTests/UriProperties.cs @@ -0,0 +1,511 @@ +#region Usings declarations + +using FsCheck; +using FsCheck.Fluent; + +using JetBrains.Annotations; + +#endregion + +namespace JustDummies.PropertyTests; + +/// +/// Property-based tests for the family. The example-based suite pins one host +/// (api.example.com), one port (8443) and one segment count (3), and can only prove the +/// builder right for those; these quantify over the whole option space of each family — every host the library +/// accepts, every port in 1..65535, every small segment count, and every on/off combination of the optional +/// components — so a shape that renders an unparsable URI for one combination is found and shrunk to its minimal +/// counter-example rather than missed. +/// +/// +/// The family narrowings are a typed progression: a category error such as a port on a mailto or a fragment +/// on a WebSocket is a compile error, not an exception, so there is nothing here to assert about it. Only two +/// conflicts survive to run time — a second scheme constraint and a second path constraint — and both are proven +/// below over arbitrary arguments. +/// +[TestSubject(typeof(AnyUri))] +public sealed class UriProperties { + + private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz"; + private const string LowerAlphaNum = "abcdefghijklmnopqrstuvwxyz0123456789"; + private const string Unreserved = "abcdefghijklmnopqrstuvwxyz0123456789-_"; + + /// + /// Which path constraint a case declares. Unconstrained declares none at all — the third state a + /// nullable segment count cannot express, and the only one that leaves the segment count to the draw. + /// + private enum PathChoice { + + Unconstrained, + Root, + Exact + + } + + /// Which of the three WithUserInfo overloads a case calls. + private enum UserInfoChoice { + + Arbitrary, // WithUserInfo() — both parts drawn + UserOnly, // WithUserInfo(user) — user pinned, password drawn + UserAndPassword // WithUserInfo(user, password) — both parts pinned + + } + + #region Statics members declarations + + /// + /// The schemes the library is allowed to emit. file is deliberately absent: a file path does not + /// round-trip identically across target frameworks, so the unconstrained draw must never reach it. + /// + private static readonly HashSet EmittableSchemes = new() { "http", "https", "ws", "wss", "ftp", "mailto" }; + + /// + /// Arbitrary hosts the library accepts, drawn from the very alphabet UriSpec draws its own hosts from: a + /// DNS label, optionally followed by a second one. Staying inside that alphabet keeps every generated host on + /// the legal side of WithHost, so the property exercises the pinning rather than the validation. + /// + private static Gen Hosts() { + return from first in Labels() + from second in Gen.OneOf(Gen.Constant(string.Empty), Labels().Select(label => "." + label)) + select first + second; + } + + /// + /// A DNS-safe label: one letter, then up to seven letters or digits. Capping the tail matters — an unbounded + /// FsCheck list would eventually exceed the 63-character label ceiling and turn a pinning property into an + /// argument-validation one. + /// + private static Gen Labels() { + return from head in Gen.Elements(LowerLetters.ToCharArray()) + from tail in Gen.ListOf(Gen.Elements(LowerAlphaNum.ToCharArray())) + select head.ToString() + new string(tail.Take(7).ToArray()); + } + + /// + /// Arbitrary user-info parts and mailto local-parts: non-empty, starting with a letter or a digit, and drawn + /// from the unreserved characters RequireUserInfoPart accepts. + /// + private static Gen UnreservedParts() { + return from head in Gen.Elements(LowerAlphaNum.ToCharArray()) + from tail in Gen.ListOf(Gen.Elements(Unreserved.ToCharArray())) + select head.ToString() + new string(tail.Take(7).ToArray()); + } + + /// Arbitrary legal ports — the whole 1..65535 range, not the two or three a hand-written test would pick. + private static Gen Ports() { + return Gen.Choose(1, 65535); + } + + /// An option the caller may leave undeclared: null stands for "the call was never made". + private static Gen Optional(Gen values) + where T : struct { + return Gen.OneOf(Gen.Constant((T?)null), values.Select(value => (T?)value)); + } + + /// Counts the non-empty segments of a path, the way a reader counts the slashes. + private static int SegmentCount(string path) { + return path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries).Length; + } + + /// Applies one of the two web scheme pins — the pair that conflicts with itself in either order. + private static AnyWebUri PinScheme(AnyWebUri generator, bool secure) { + return secure ? generator.UsingHttps() : generator.UsingHttp(); + } + + /// Applies one of the two WebSocket scheme pins — the pair that conflicts with itself in either order. + private static AnyWebSocketUri PinScheme(AnyWebSocketUri generator, bool secure) { + return secure ? generator.UsingWss() : generator.UsingWs(); + } + + /// Declares a path constraint: a segment count, or the root path when is null. + private static AnyWebUri PinPath(AnyWebUri generator, int? segments) { + return segments.HasValue ? generator.WithPathSegments(segments.Value) : generator.WithoutPath(); + } + + #endregion + + [Fact(DisplayName = "Every unconstrained draw is a valid URI of an emittable family, whatever the seed.")] + public void UnconstrainedDrawsAreValidUris() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => Expect.EveryDraw(Any.WithSeed(seed).Uri(), + value => { + UriKind kind = value.IsAbsoluteUri ? UriKind.Absolute : UriKind.Relative; + + return value.OriginalString.Length > 0 + // Every component is ASCII by construction: an internationalized host + // would not round-trip identically across target frameworks. + && value.OriginalString.All(character => character < 128) + && (!value.IsAbsoluteUri || EmittableSchemes.Contains(value.Scheme)) + && Uri.TryCreate(value.OriginalString, kind, out _); + }, + 16)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "The unconstrained draw reaches all five families, whatever the seed.")] + public void UnconstrainedReachesEveryFamily() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + // One family in five per draw, so 120 draws leave a miss far below any rate that could make + // this flaky, while a hand-written test can only ever assert it for the one seed it picked. + HashSet seen = new(); + foreach (Uri value in Expect.Draws(Any.WithSeed(seed).Uri(), 120)) { + seen.Add(value.IsAbsoluteUri ? value.Scheme : "relative"); + } + + return (seen.Contains("http") || seen.Contains("https")) + && (seen.Contains("ws") || seen.Contains("wss")) + && seen.Contains("ftp") + && seen.Contains("mailto") + && seen.Contains("relative"); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A web draw carries exactly the components declared on it, in every combination.")] + public void WebDrawsCarryTheDeclaredComponents() { + Gen<(string Host, int? Port, PathChoice Path, int Segments, bool? Secure, bool Query, bool Fragment)> cases = + from host in Hosts() + from port in Optional(Ports()) + from path in Gen.Elements(PathChoice.Unconstrained, PathChoice.Root, PathChoice.Exact) + from segments in Generators.Count(6) + from secure in Optional(Gen.Elements(true, false)) + from query in Gen.Elements(true, false) + from fragment in Gen.Elements(true, false) + select (host, port, path, segments, secure, query, fragment); + + Prop.ForAll(cases.ToArbitrary(), + testCase => { + AnyWebUri generator = Any.Uri().Web().WithHost(testCase.Host); + if (testCase.Secure.HasValue) { generator = PinScheme(generator, testCase.Secure.Value); } + if (testCase.Port.HasValue) { generator = generator.WithPort(testCase.Port.Value); } + if (testCase.Path == PathChoice.Root) { generator = generator.WithoutPath(); } + if (testCase.Path == PathChoice.Exact) { generator = generator.WithPathSegments(testCase.Segments); } + if (testCase.Query) { generator = generator.WithQuery(); } + if (testCase.Fragment) { generator = generator.WithFragment(); } + + return Expect.EveryDraw(generator, + value => { + bool pathHolds = testCase.Path switch { + PathChoice.Root => value.AbsolutePath == "/", + PathChoice.Exact => SegmentCount(value.AbsolutePath) == testCase.Segments, + // An undeclared path draws 0 to 2 segments. + _ => SegmentCount(value.AbsolutePath) <= 2 + }; + + return value.IsAbsoluteUri + && (testCase.Secure.HasValue + ? value.Scheme == (testCase.Secure.Value ? "https" : "http") + : value.Scheme is "http" or "https") + && value.Host == testCase.Host + && (!testCase.Port.HasValue || value.Port == testCase.Port.Value) + && pathHolds + && value.UserInfo.Length == 0 + && (value.Query.Length > 0) == testCase.Query + && (value.Fragment.Length > 0) == testCase.Fragment; + }); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "Declared user-info reaches the URI, whichever of the three overloads declared it.")] + public void UserInfoShapesReachTheUri() { + Gen<(UserInfoChoice Choice, string User, string Password)> cases = + from choice in Gen.Elements(UserInfoChoice.Arbitrary, UserInfoChoice.UserOnly, UserInfoChoice.UserAndPassword) + from user in UnreservedParts() + from password in UnreservedParts() + select (choice, user, password); + + Prop.ForAll(cases.ToArbitrary(), + testCase => { + AnyWebUri generator = testCase.Choice switch { + UserInfoChoice.UserOnly => Any.Uri().Web().WithUserInfo(testCase.User), + UserInfoChoice.UserAndPassword => Any.Uri().Web().WithUserInfo(testCase.User, testCase.Password), + _ => Any.Uri().Web().WithUserInfo() + }; + + return Expect.EveryDraw(generator, + value => testCase.Choice switch { + // Only the user is pinned, so the password is merely required to be there. + UserInfoChoice.UserOnly => value.UserInfo.StartsWith(testCase.User + ":", StringComparison.Ordinal) + && value.UserInfo.Length > testCase.User.Length + 1, + UserInfoChoice.UserAndPassword => value.UserInfo == testCase.User + ":" + testCase.Password, + _ => value.UserInfo.IndexOf(':') > 0 + }); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A WebSocket draw uses ws or wss and never carries user-info or a fragment.")] + public void WebSocketDrawsAreWebSocketUris() { + Gen<(string Host, PathChoice Path, int Segments, bool? Secure, bool Query)> cases = + from host in Hosts() + from path in Gen.Elements(PathChoice.Unconstrained, PathChoice.Root, PathChoice.Exact) + from segments in Generators.Count(6) + from secure in Optional(Gen.Elements(true, false)) + from query in Gen.Elements(true, false) + select (host, path, segments, secure, query); + + Prop.ForAll(cases.ToArbitrary(), + testCase => { + AnyWebSocketUri generator = Any.Uri().WebSocket().WithHost(testCase.Host); + if (testCase.Secure.HasValue) { generator = PinScheme(generator, testCase.Secure.Value); } + if (testCase.Path == PathChoice.Root) { generator = generator.WithoutPath(); } + if (testCase.Path == PathChoice.Exact) { generator = generator.WithPathSegments(testCase.Segments); } + if (testCase.Query) { generator = generator.WithQuery(); } + + return Expect.EveryDraw(generator, + value => { + // Asserted on the rendered string rather than on the parsed components: + // ws and wss are not authority-parsed identically on every framework, and + // the rendering is what the library actually promises. + string rendered = value.OriginalString; + + return value.IsAbsoluteUri + && (testCase.Secure.HasValue + ? value.Scheme == (testCase.Secure.Value ? "wss" : "ws") + : value.Scheme is "ws" or "wss") + && rendered.StartsWith(value.Scheme + "://" + testCase.Host, StringComparison.Ordinal) + && (rendered.IndexOf('?') >= 0) == testCase.Query + && rendered.IndexOf('#') < 0 + && rendered.IndexOf('@') < 0; + }); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "An FTP draw uses the ftp scheme with its user-info, and never a query or a fragment.")] + public void FtpDrawsAreFtpUris() { + Gen<(string Host, int? Port, PathChoice Path, int Segments, bool Credentials, string User, string Password)> cases = + from host in Hosts() + from port in Optional(Ports()) + from path in Gen.Elements(PathChoice.Unconstrained, PathChoice.Root, PathChoice.Exact) + from segments in Generators.Count(6) + from credentials in Gen.Elements(true, false) + from user in UnreservedParts() + from password in UnreservedParts() + select (host, port, path, segments, credentials, user, password); + + Prop.ForAll(cases.ToArbitrary(), + testCase => { + AnyFtpUri generator = Any.Uri().Ftp().WithHost(testCase.Host); + if (testCase.Port.HasValue) { generator = generator.WithPort(testCase.Port.Value); } + if (testCase.Path == PathChoice.Root) { generator = generator.WithoutPath(); } + if (testCase.Path == PathChoice.Exact) { generator = generator.WithPathSegments(testCase.Segments); } + if (testCase.Credentials) { generator = generator.WithUserInfo(testCase.User, testCase.Password); } + + return Expect.EveryDraw(generator, + value => value.IsAbsoluteUri + && value.Scheme == "ftp" + && value.Host == testCase.Host + && (!testCase.Port.HasValue || value.Port == testCase.Port.Value) + && value.UserInfo == (testCase.Credentials ? testCase.User + ":" + testCase.Password : string.Empty) + // An FTP URI has neither, and the builder does not even expose them. + && value.OriginalString.IndexOf('?') < 0 + && value.OriginalString.IndexOf('#') < 0); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A mailto draw renders local@domain, honouring whichever part is pinned.")] + public void MailtoDrawsRenderTheDeclaredAddress() { + Gen<(string Local, string Domain, bool PinLocal, bool PinDomain, bool Headers)> cases = + from local in UnreservedParts() + from domain in Hosts() + from pinLocal in Gen.Elements(true, false) + from pinDomain in Gen.Elements(true, false) + from headers in Gen.Elements(true, false) + select (local, domain, pinLocal, pinDomain, headers); + + Prop.ForAll(cases.ToArbitrary(), + testCase => { + AnyMailtoUri generator = Any.Uri().Mailto(); + if (testCase.PinLocal) { generator = generator.WithLocalPart(testCase.Local); } + if (testCase.PinDomain) { generator = generator.WithDomain(testCase.Domain); } + if (testCase.Headers) { generator = generator.WithHeaders(); } + + return Expect.EveryDraw(generator, + value => { + if (!value.IsAbsoluteUri || value.Scheme != "mailto") { return false; } + if (!value.OriginalString.StartsWith("mailto:", StringComparison.Ordinal)) { return false; } + + string address = value.OriginalString.Substring("mailto:".Length); + int headerStart = address.IndexOf('?'); + if ((headerStart >= 0) != testCase.Headers) { return false; } + if (headerStart >= 0) { address = address.Substring(0, headerStart); } + + string[] parts = address.Split(new[] { '@' }); + + return parts.Length == 2 + && parts[0].Length > 0 + && parts[1].Length > 0 + && (!testCase.PinLocal || parts[0] == testCase.Local) + && (!testCase.PinDomain || parts[1] == testCase.Domain); + }); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A relative draw is a relative reference carrying exactly the declared path, query and fragment.")] + public void RelativeDrawsAreRelativeReferences() { + Gen<(int? Segments, bool Rooted, bool Query, bool Fragment)> cases = + from segments in Optional(Generators.Count(6)) + from rooted in Gen.Elements(true, false) + from query in Gen.Elements(true, false) + from fragment in Gen.Elements(true, false) + select (segments, rooted, query, fragment); + + Prop.ForAll(cases.ToArbitrary(), + testCase => { + AnyRelativeUri generator = Any.Uri().Relative(); + if (testCase.Rooted) { generator = generator.Rooted(); } + if (testCase.Segments.HasValue) { generator = generator.WithPathSegments(testCase.Segments.Value); } + if (testCase.Query) { generator = generator.WithQuery(); } + if (testCase.Fragment) { generator = generator.WithFragment(); } + + // An explicit zero-segment path with nothing else to carry it renders the empty string, which is + // not a valid reference: the one shape of this family that cannot generate. + if (testCase.Segments == 0 && !testCase.Rooted && !testCase.Query && !testCase.Fragment) { + return Expect.Throws(() => generator.Generate()); + } + + return Expect.EveryDraw(generator, + value => { + string reference = value.OriginalString; + int cut = reference.IndexOfAny(new[] { '?', '#' }); + int segments = SegmentCount(cut < 0 ? reference : reference.Substring(0, cut)); + + return !value.IsAbsoluteUri + && reference.Length > 0 + && (!testCase.Rooted || reference.StartsWith("/", StringComparison.Ordinal)) + && (testCase.Segments.HasValue ? segments == testCase.Segments.Value : segments <= 2) + && (reference.IndexOf('?') >= 0) == testCase.Query + && (reference.IndexOf('#') >= 0) == testCase.Fragment; + }); + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "An undeclared relative path never renders empty: a zero-segment draw is resolved to one segment.")] + public void UnconstrainedRelativeNeverRendersEmpty() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + // The draw picks 0, 1 or 2 segments; the 0 that would render the empty string is silently resolved + // to a single arbitrary segment, so the count never leaves 1..2 and generation never fails. + seed => Expect.EveryDraw(Any.WithSeed(seed).Uri().Relative(), + value => !value.IsAbsoluteUri + && value.OriginalString.Length > 0 + && SegmentCount(value.OriginalString) is 1 or 2, + 24)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "An explicit zero-segment relative path with nothing else fails at generation, carrying the seed.")] + public void EmptyRelativeFailsAtGenerationCarryingTheSeed() { + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + try { + Any.WithSeed(seed).Uri().Relative().WithPathSegments(0).Generate(); + + return false; + } catch (AnyGenerationException error) { + return error.Seed == seed; + } + }) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "WithPort pins the port over the whole legal range, and rejects anything outside it as an argument.")] + public void PortsArePinnedOrRejectedAsArguments() { + Gen candidates = Generators.WithEdges(Gen.OneOf(Ports(), Generators.Int32()), + -1, 0, 1, 65535, 65536, int.MinValue, int.MaxValue); + + Prop.ForAll(candidates.ToArbitrary(), + port => port is < 1 or > 65535 + ? Expect.Throws(() => Any.Uri().Web().WithPort(port)) + : Expect.EveryDraw(Any.Uri().Web().WithPort(port), value => value.Port == port)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "The argument-less WithPort yields an explicit, non-default port on every draw.")] + public void ArbitraryPortsAreExplicitAndNonDefault() { + Prop.ForAll(Hosts().ToArbitrary(), + host => Expect.EveryDraw(Any.Uri().Web().WithHost(host).WithPort(), + // Drawn above every default the library emits, so the port is always visible. + value => value.Port >= 1025 && value.Port <= 65535 && !value.IsDefaultPort)) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A second scheme pin conflicts, whichever pair and whichever order.")] + public void SecondSchemePinConflicts() { + Gen<(bool First, bool Second)> cases = from first in Gen.Elements(true, false) + from second in Gen.Elements(true, false) + select (first, second); + + Prop.ForAll(cases.ToArbitrary(), + testCase => Expect.Throws( + () => PinScheme(PinScheme(Any.Uri().Web(), testCase.First), testCase.Second)) + && Expect.Throws( + () => PinScheme(PinScheme(Any.Uri().WebSocket(), testCase.First), testCase.Second))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A second path constraint conflicts, whichever pair and whichever segment counts.")] + public void SecondPathConstraintConflicts() { + // null stands for WithoutPath(), a count for WithPathSegments(count): all four ordered pairs conflict. + Gen<(int? First, int? Second)> cases = from first in Optional(Generators.Count(6)) + from second in Optional(Generators.Count(6)) + select (first, second); + + Prop.ForAll(cases.ToArbitrary(), + testCase => Expect.Throws( + () => PinPath(PinPath(Any.Uri().Web(), testCase.First), testCase.Second)) + // A relative reference has no WithoutPath(), so only the doubled segment count applies. + && Expect.Throws( + () => Any.Uri().Relative().WithPathSegments(testCase.First ?? 0).WithPathSegments(testCase.Second ?? 0))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A negative segment count is an argument error even when a path constraint already conflicts with it.")] + public void NegativeSegmentCountsAreArgumentErrorsBeforeConflicts() { + Gen negatives = Generators.WithEdges(Generators.Count(8).Select(offset => -1 - offset), -1, int.MinValue); + + Gen<(int? Declared, int Count)> cases = from declared in Optional(Generators.Count(6)) + from count in negatives + select (declared, count); + + Prop.ForAll(cases.ToArbitrary(), + // Argument validation runs before conflict checking, so the argument error wins over the conflict + // the second path constraint would otherwise raise. + testCase => Expect.Throws( + () => PinPath(Any.Uri().Web(), testCase.Declared).WithPathSegments(testCase.Count))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A host carrying a non-ASCII character is rejected as an argument, wherever the character sits.")] + public void NonAsciiHostsAreRejectedAsArguments() { + Gen cases = from host in Hosts() + from character in Gen.Elements('é', 'ü', 'ñ', 'ß', 'д', '中') + from index in Gen.Choose(0, host.Length) + select host.Insert(index, character.ToString()); + + Prop.ForAll(cases.ToArbitrary(), + // An internationalized host is refused at the call site, pointing at punycode — never silently + // accepted, because it would not round-trip identically across target frameworks. + spoiled => Expect.Throws(() => Any.Uri().Web().WithHost(spoiled))) + .QuickCheckThrowOnFailure(); + } + + [Fact(DisplayName = "A user-info part carrying a reserved character is rejected as an argument, wherever it sits.")] + public void ReservedUserInfoCharactersAreRejectedAsArguments() { + Gen cases = from part in UnreservedParts() + from character in Gen.Elements(':', '/', '?', '#', '[', ']', '@', '!', '$', '&', '(', ')', '*', '+', ',', ';', '=', '%', ' ') + from index in Gen.Choose(0, part.Length) + select part.Insert(index, character.ToString()); + + Prop.ForAll(cases.ToArbitrary(), + spoiled => Expect.Throws(() => Any.Uri().Web().WithUserInfo(spoiled)) + && Expect.Throws(() => Any.Uri().Mailto().WithLocalPart(spoiled))) + .QuickCheckThrowOnFailure(); + } + +} diff --git a/JustDummies.UnitTests/AnyContinuousTests.cs b/JustDummies.UnitTests/AnyContinuousTests.cs index 2efe4118..c1843a27 100644 --- a/JustDummies.UnitTests/AnyContinuousTests.cs +++ b/JustDummies.UnitTests/AnyContinuousTests.cs @@ -6,18 +6,17 @@ namespace JustDummies.UnitTests; +/// +/// The example-based half of the continuous generators' contract: conflict messages, the named domain +/// extremes, the exclusion families the property suite leaves alone, and the seeded regression for issue +/// #206. Containment, strictness, inclusiveness, sign handling and the rejection of non-finite arguments +/// hold for every bound and are quantified in JustDummies.PropertyTests (ADR-0040); the #206 +/// regression stays here because it pins the interval where the defect actually occurred. +/// public sealed class AnyContinuousTests { private const int SampleCount = 200; - [Fact(DisplayName = "Double: unconstrained draws are always finite.")] - public void DoubleIsFinite() { - for (int i = 0; i < SampleCount; i++) { - double value = Any.Double().Generate(); - Check.That(double.IsNaN(value) || double.IsInfinity(value)).IsFalse(); - } - } - [Fact(DisplayName = "Double: sign constraints are strict, Zero pins, NonZero excludes.")] public void DoubleSignFamily() { for (int i = 0; i < SampleCount; i++) { @@ -46,15 +45,6 @@ public void DoubleBounds() { Check.ThatCode(() => Any.Double().GreaterThan(double.MaxValue)).Throws(); } - [Fact(DisplayName = "Double: non-finite arguments are rejected as argument errors.")] - public void DoubleRejectsNonFinite() { - Check.ThatCode(() => Any.Double().GreaterThan(double.NaN)).Throws(); - Check.ThatCode(() => Any.Double().LessThan(double.PositiveInfinity)).Throws(); - Check.ThatCode(() => Any.Double().Between(double.NegativeInfinity, 0d)).Throws(); - Check.ThatCode(() => Any.Double().OneOf(1d, double.NaN)).Throws(); - Check.ThatCode(() => Any.Double().Between(10d, 1d)).Throws(); - } - [Fact(DisplayName = "Double: OneOf stays within and Except/DifferentFrom never yield the excluded value.")] public void DoubleSets() { double[] allowed = [1.5d, 2.5d]; diff --git a/JustDummies.UnitTests/AnyInt32Tests.cs b/JustDummies.UnitTests/AnyInt32Tests.cs index bf21eeb4..6af6f426 100644 --- a/JustDummies.UnitTests/AnyInt32Tests.cs +++ b/JustDummies.UnitTests/AnyInt32Tests.cs @@ -8,6 +8,13 @@ namespace JustDummies.UnitTests; +/// +/// The example-based half of 's contract: what a conflict message must name, which +/// arguments are rejected outright, that the named domain extremes are generable, and that a bounded range +/// is actually reached. The invariants that hold for every bound — containment, strictness, +/// inclusiveness, exclusion, immutability — are quantified over generated bounds in +/// JustDummies.PropertyTests instead, and are deliberately not restated here (ADR-0040). +/// [TestSubject(typeof(AnyInt32))] public sealed class AnyInt32Tests { @@ -54,19 +61,6 @@ public void NonZeroIsNeverZero() { } } - [Fact(DisplayName = "Between yields values within the inclusive bounds.")] - public void BetweenStaysWithinBounds() { - foreach (int value in Samples(Any.Int32().Between(10, 20))) { - Check.That(value).IsGreaterOrEqualThan(10); - Check.That(value).IsLessOrEqualThan(20); - } - } - - [Fact(DisplayName = "Between with equal bounds pins the value.")] - public void BetweenWithEqualBoundsPins() { - Check.That(Any.Int32().Between(5, 5).Generate()).IsEqualTo(5); - } - [Fact(DisplayName = "Between eventually reaches both inclusive bounds.")] public void BetweenReachesItsBounds() { HashSet seen = new(Samples(Any.Int32().Between(1, 3))); @@ -75,47 +69,12 @@ public void BetweenReachesItsBounds() { Check.That(seen.Contains(3)).IsTrue(); } - [Fact(DisplayName = "GreaterThan is exclusive, GreaterThanOrEqualTo is inclusive.")] - public void LowerBoundsAreExactlyExclusiveOrInclusive() { - foreach (int value in Samples(Any.Int32().GreaterThan(10).LessThanOrEqualTo(12))) { - Check.That(value).IsGreaterOrEqualThan(11); - } - - HashSet seen = new(Samples(Any.Int32().GreaterThanOrEqualTo(10).LessThanOrEqualTo(11))); - Check.That(seen.Contains(10)).IsTrue(); - } - - [Fact(DisplayName = "LessThan is exclusive, LessThanOrEqualTo is inclusive.")] - public void UpperBoundsAreExactlyExclusiveOrInclusive() { - foreach (int value in Samples(Any.Int32().LessThan(10).GreaterThanOrEqualTo(8))) { - Check.That(value).IsLessOrEqualThan(9); - } - - HashSet seen = new(Samples(Any.Int32().LessThanOrEqualTo(10).GreaterThanOrEqualTo(9))); - Check.That(seen.Contains(10)).IsTrue(); - } - [Fact(DisplayName = "The extreme bounds of the Int32 range are generable.")] public void ExtremeBoundsAreGenerable() { Check.That(Any.Int32().LessThanOrEqualTo(int.MinValue).Generate()).IsEqualTo(int.MinValue); Check.That(Any.Int32().GreaterThanOrEqualTo(int.MaxValue).Generate()).IsEqualTo(int.MaxValue); } - [Fact(DisplayName = "OneOf yields only the supplied values.")] - public void OneOfStaysWithinTheSuppliedValues() { - int[] allowed = [1, 5, 9]; - foreach (int value in Samples(Any.Int32().OneOf(allowed))) { - Check.That(allowed.Contains(value)).IsTrue(); - } - } - - [Fact(DisplayName = "Except never yields an excluded value.")] - public void ExceptNeverYieldsAnExcludedValue() { - foreach (int value in Samples(Any.Int32().Between(1, 3).Except(2))) { - Check.That(value).IsNotEqualTo(2); - } - } - [Fact(DisplayName = "DifferentFrom never yields the excluded value.")] public void DifferentFromNeverYieldsTheValue() { foreach (int value in Samples(Any.Int32().Between(7, 8).DifferentFrom(7))) { @@ -179,11 +138,6 @@ public void ExceptExhaustingTheAllowListConflicts() { Check.ThatCode(() => Any.Int32().OneOf(1, 2).Except(1).Except(2)).Throws(); } - [Fact(DisplayName = "Between with crossed arguments is an argument error, not a conflict.")] - public void BetweenWithCrossedArgumentsIsAnArgumentError() { - Check.ThatCode(() => Any.Int32().Between(10, 1)).Throws(); - } - [Fact(DisplayName = "OneOf and Except reject null or empty value lists.")] public void OneOfAndExceptRejectNullOrEmpty() { Check.ThatCode(() => Any.Int32().OneOf()).Throws(); @@ -192,15 +146,4 @@ public void OneOfAndExceptRejectNullOrEmpty() { Check.ThatCode(() => Any.Int32().Except(null!)).Throws(); } - [Fact(DisplayName = "A constrained generator is a new instance: the original is unchanged.")] - public void ConstrainingReturnsANewGenerator() { - AnyInt32 original = Any.Int32().Between(1, 10); - AnyInt32 constrained = original.GreaterThanOrEqualTo(10); - - Check.That(ReferenceEquals(constrained, original)).IsFalse(); - // The original still generates over its own, wider domain. - HashSet seen = new(Samples(original)); - Check.That(seen.Count).IsStrictlyGreaterThan(1); - } - } diff --git a/JustDummies.UnitTests/SeedReproducibilityTests.cs b/JustDummies.UnitTests/SeedReproducibilityTests.cs index a5358328..fc4fd175 100644 --- a/JustDummies.UnitTests/SeedReproducibilityTests.cs +++ b/JustDummies.UnitTests/SeedReproducibilityTests.cs @@ -8,6 +8,12 @@ namespace JustDummies.UnitTests; +/// +/// The example-based half of the reproducibility contract: what the failure report must say, that a +/// successful run stays silent, the asynchronous overloads, and the null-argument guards. That two runs +/// under the same seed agree — and that different seeds diverge — holds for every seed and is +/// quantified in JustDummies.PropertyTests instead of pinned to 12345, 777 and 31415 (ADR-0040). +/// [TestSubject(typeof(Any))] public sealed class SeedReproducibilityTests { @@ -51,71 +57,6 @@ private static string Batch() { #endregion - [Fact(DisplayName = "Two contexts created with the same seed yield the same values.")] - public void SameSeedContextsAgree() { - AnyContext any1 = Any.WithSeed(12345); - AnyContext any2 = Any.WithSeed(12345); - - string value1 = any1.String().NonEmpty().Generate(); - string value2 = any2.String().NonEmpty().Generate(); - - Check.That(value2).IsEqualTo(value1); - } - - [Fact(DisplayName = "Two contexts with the same seed agree across a mixed sequence of draws.")] - public void SameSeedContextsAgreeAcrossASequence() { - AnyContext any1 = Any.WithSeed(777); - AnyContext any2 = Any.WithSeed(777); - - string sequence1 = $"{any1.Int32().Positive().Generate()}|{any1.String().WithLength(8).Generate()}|{any1.Int32().Between(0, 9).Generate()}"; - string sequence2 = $"{any2.Int32().Positive().Generate()}|{any2.String().WithLength(8).Generate()}|{any2.Int32().Between(0, 9).Generate()}"; - - Check.That(sequence2).IsEqualTo(sequence1); - } - - [Fact(DisplayName = "Contexts with different seeds diverge.")] - public void DifferentSeedContextsDiverge() { - string sequence1 = string.Join("|", Enumerable.Range(0, 8).Select(_ => Any.WithSeed(1).String().WithLength(12).Generate())); - string sequence2 = string.Join("|", Enumerable.Range(0, 8).Select(_ => Any.WithSeed(2).String().WithLength(12).Generate())); - - Check.That(sequence2).IsNotEqualTo(sequence1); - } - - [Fact(DisplayName = "A context is isolated from the ambient source: interleaved ambient draws do not shift it.")] - public void ContextIsIsolatedFromAmbientDraws() { - AnyContext quiet = Any.WithSeed(31415); - string undisturbed = quiet.String().WithLength(10).Generate(); - - AnyContext interleaved = Any.WithSeed(31415); - Any.String().Generate(); - Any.Int32().Generate(); - string disturbed = interleaved.String().WithLength(10).Generate(); - - Check.That(disturbed).IsEqualTo(undisturbed); - } - - [Fact(DisplayName = "Reproducibly with a given seed replays the same sequence of values.")] - public void ReproduciblyWithASeedIsDeterministic() { - string first = string.Empty; - string second = string.Empty; - - Any.Reproducibly(1234, () => { first = Batch(); }); - Any.Reproducibly(1234, () => { second = Batch(); }); - - Check.That(second).IsEqualTo(first); - } - - [Fact(DisplayName = "Reproducibly with different seeds produces different sequences.")] - public void DifferentSeedsDiffer() { - string fromOne = string.Empty; - string fromTwo = string.Empty; - - Any.Reproducibly(1, () => { fromOne = Batch(); }); - Any.Reproducibly(2, () => { fromTwo = Batch(); }); - - Check.That(fromTwo).IsNotEqualTo(fromOne); - } - [Fact(DisplayName = "Reproducibly reports the seed and rethrows the original exception on failure.")] public void ReproduciblyReportsTheSeedAndRethrows() { string? reported = null; diff --git a/doc/handwritten/for-maintainers/README.fr.md b/doc/handwritten/for-maintainers/README.fr.md index 062a49f3..9fcf206a 100644 --- a/doc/handwritten/for-maintainers/README.fr.md +++ b/doc/handwritten/for-maintainers/README.fr.md @@ -34,6 +34,15 @@ La checklist pour ajouter un nouveau paquet versionné indépendamment : l'uniqu imposés par GitHub et la tooling (trigger de tag, options de choix, scopes du commit-lint, packaging). Aussi en [anglais](AddingAReleaseTrain.en.md). +### [Écrire les tests de JustDummies](WritingJustDummiesTests.fr.md) + +Où placer un nouveau test pour `JustDummies` — suite par l'exemple ou suite par +propriétés — et comment l'écrire pour qu'il prouve quelque chose. Une seule +question tranche : l'assertion a-t-elle un espace d'entrée ? La frontière +elle-même est enregistrée dans +[l'ADR-0040](adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md) ; +cette page l'applique. Aussi en [anglais](WritingJustDummiesTests.en.md). + ### [Registres de décision d'architecture (ADR)](adr/README.md) Des enregistrements datés des décisions importantes — leur contexte, l'option diff --git a/doc/handwritten/for-maintainers/README.md b/doc/handwritten/for-maintainers/README.md index 0bfa1817..79331649 100644 --- a/doc/handwritten/for-maintainers/README.md +++ b/doc/handwritten/for-maintainers/README.md @@ -32,6 +32,14 @@ edit in [`tools/trains.sh`](../../../tools/trains.sh) and the static edits GitHu tooling force (tag trigger, choice options, commit-lint scopes, packing). Also in [French](AddingAReleaseTrain.fr.md). +### [Writing JustDummies tests](WritingJustDummiesTests.en.md) + +Where a new test for `JustDummies` belongs — the example suite or the property +suite — and how to write it so it proves something. One question decides it: +does the assertion have an input space? The boundary itself is recorded in +[ADR-0040](adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.md); +this page applies it. Also in [French](WritingJustDummiesTests.fr.md). + ### [Architecture Decision Records](adr/README.md) Dated records of significant decisions — their context, the option chosen, and diff --git a/doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md b/doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md new file mode 100644 index 00000000..81806e10 --- /dev/null +++ b/doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md @@ -0,0 +1,128 @@ +# Writing JustDummies tests + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](WritingJustDummiesTests.fr.md) + +> Where a new test for `JustDummies` belongs, and how to write it. The boundary +> between the two suites is recorded in +> [ADR-0040](adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.md); +> this page is how to apply it. + +## The two suites + +| Project | Owns | Style | +|---|---|---| +| `JustDummies.PropertyTests` | Invariants that hold for **every** legal constraint argument | FsCheck properties over generated constraints | +| `JustDummies.UnitTests` | Contracts whose subject is a **specific, named case** | xUnit + NFluent examples | + +Both run on `net10.0` and on the .NET Framework 4.7.2 floor, so both prove their +half against the `netstandard2.0` asset consumers actually load. + +## The one question to ask + +> *Does my assertion have an input space?* + +Something a caller could have passed differently — a bound, a length, a count, a +pool, a seed, a pattern, an offset — and the assertion should still hold? + +* **Yes** → it is a property. Generate that input and quantify over it. +* **No** → it is an example. Pin it and assert it directly. + +That is the whole rule. Everything below is it applied. + +### Goes in the property suite + +* **Containment and strictness.** `Between(min, max)` contains; `GreaterThan` is + strict; `GreaterThanOrEqualTo` admits its own bound. The bound is the input space. +* **Shape.** `WithLength(n)` produces exactly `n`; `StartingWith(prefix)` actually + starts with it; `WithCount(n)` yields exactly `n` elements. +* **Grids.** `MultipleOf`, `WithScale`, `WithGranularity` — the step is the input + space, and the anchor differs per type, which is where these go wrong. +* **Round trips.** A value generated from a pattern is matched by the real engine; + a generated URI parses and carries the shape asked for. +* **Determinism.** Two contexts on the same seed agree — for *every* seed, not for + 12345. +* **Composition.** `As`, `OrNull`, `Combine`, the explicit pools: the composed + value carries each part's constraint, whatever the parts were constrained to. +* **Value-dependent legality.** When the same call is legal or illegal depending on + its argument, a property is the only honest way to state it — branch on the + value, never on the call shape. + +### Stays in the example suite + +* **Message content.** A conflict must name *both* offending constraints. The + wording is direction-aware; assert it on a pinned case, and assert the exception + **type** anywhere else. +* **Null and blank arguments.** `null` has no input space. +* **Named domain extremes.** `int.MinValue`, `byte.MaxValue`, an empty pool — a + specific coordinate, cheaper and clearer pinned than quantified. +* **Reachability.** That a bounded range is actually reached, that both branches of + a coin flip are observed. These are statistical, not universal — pin a seed. +* **Structural conventions.** The `Any` ↔ `AnyContext` mirror, factory naming, the + standalone assembly boundary. Reflection over a fixed expectation table; there is + no input to generate. +* **Dated regressions.** A defect that actually occurred, pinned at the coordinates + where it occurred. A property covering the same ground does **not** retire it — + the specificity is the value. Reference the issue in a comment. + +## Adding a feature + +1. Write the example tests first — they are how you discover the shape, and they + own the conflict messages your new constraint must produce. +2. Then ask the question above of each invariant you wrote. Anything that holds for + every argument moves to a property, and the example that pinned one argument goes + away with it. +3. If your constraint interacts with an existing one, the interaction is almost + always a property: it has two input spaces. + +## Fixing a defect + +1. Pin the defect as an example at the coordinates where it was found, with the + issue number in a comment. That is the regression, and it stays forever. +2. Then ask whether the defect had an input space the example does not cover. + Issue #206 did — the decimal midpoint bug was found on one interval and lived on + all of them. Add the property too. +3. Both land. The regression proves the exact case; the property proves the class. + +## Writing the property + +Use the shared helpers in `PropertyTestSupport.cs`: + +* `Generators.OrderedPair(values)` — a well-formed `(min, max)`, degenerate pairs + included. Pinned intervals are a historically fragile corner; do not filter them + out, branch on them. +* `Generators.WithEdges(values, edges)` — FsCheck's numeric generators are + size-bounded and cluster near zero, so the domain ends would otherwise almost + never be drawn. That is exactly where an off-by-one lives. +* `Expect.EveryDraw(generator, invariant)` — a generator is a recipe, not a value, + so one draw per case tests almost none of its randomness. Eight draws per case, + over a hundred cases, is the default. +* `Expect.Draws(generator, count)` — when the property reasons over a batch + (distinctness, reachability) rather than over each value alone. + +Rules that keep a property honest: + +* **Assert exception types, never message text.** Messages are direction-aware and + will change; that assertion belongs to an example. +* **Know when the exception is thrown.** Conflicts throw at the fluent *call*, not + at `Generate()`. Argument validation precedes conflict checking and wins when both + would apply. +* **Guard the degenerate corners** your generated arguments can produce — an empty + interval, a pinned interval, a zero count, an exhausted pool. Either keep the + generator off them or branch in the predicate. +* **Pin a seed for anything statistical.** "Both halves are reached", "null is + eventually drawn" are probabilistic. Under a pinned seed they are deterministic; + without one they flake. Say so in a comment. +* **Keep it fast.** A hundred cases times eight draws is already eight hundred + draws. Cap lengths and counts in the tens, not the thousands. + +## Before you push + +* `dotnet test JustDummies.PropertyTests` and `dotnet test JustDummies.UnitTests`. +* The floor legs, which CI runs and a plain `dotnet test` does not: + `dotnet build JustDummies.PropertyTests -c Release -f net472 -p:EnableNet472Floor=true`. + Anything using .NET 8+ API belongs in `ModernTypeInvariantProperties.cs`, which the + project file excludes from that leg. +* **Check your property can fail.** A property that returns `true` for the wrong + reason passes silently and proves nothing. Break it on purpose once — invert the + comparison, drop a bound — confirm it goes red, then put it back. This is the only + cheap defence against a suite that is green because it asserts nothing. diff --git a/doc/handwritten/for-maintainers/WritingJustDummiesTests.fr.md b/doc/handwritten/for-maintainers/WritingJustDummiesTests.fr.md new file mode 100644 index 00000000..998fbbd6 --- /dev/null +++ b/doc/handwritten/for-maintainers/WritingJustDummiesTests.fr.md @@ -0,0 +1,139 @@ +# Écrire les tests de JustDummies + +🌍 🇬🇧 [English](WritingJustDummiesTests.en.md) · 🇫🇷 Français (ce fichier) + +> Où placer un nouveau test pour `JustDummies`, et comment l'écrire. La frontière +> entre les deux suites est enregistrée dans +> [l'ADR-0040](adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md) ; +> cette page explique comment l'appliquer. + +## Les deux suites + +| Projet | Porte | Style | +|---|---|---| +| `JustDummies.PropertyTests` | Les invariants vrais pour **tout** argument de contrainte légal | Propriétés FsCheck sur contraintes générées | +| `JustDummies.UnitTests` | Les contrats dont le sujet est un **cas spécifique et nommé** | Exemples xUnit + NFluent | + +Les deux tournent sur `net10.0` et sur le plancher .NET Framework 4.7.2 : chacune +prouve donc sa moitié contre l'asset `netstandard2.0` que les consommateurs +chargent réellement. + +## L'unique question à se poser + +> *Mon assertion a-t-elle un espace d'entrée ?* + +Quelque chose que l'appelant aurait pu passer autrement — une borne, une longueur, +une cardinalité, un vivier, une graine, un motif, un décalage — sans que +l'assertion cesse d'être vraie ? + +* **Oui** → c'est une propriété. Générez cette entrée et quantifiez dessus. +* **Non** → c'est un exemple. Figez-le et affirmez-le directement. + +C'est toute la règle. Tout ce qui suit n'en est que l'application. + +### Va dans la suite par propriétés + +* **Contenance et stricture.** `Between(min, max)` contient ; `GreaterThan` est + strict ; `GreaterThanOrEqualTo` admet sa propre borne. La borne est l'espace + d'entrée. +* **Forme.** `WithLength(n)` produit exactement `n` ; `StartingWith(prefix)` + commence effectivement par lui ; `WithCount(n)` rend exactement `n` éléments. +* **Grilles.** `MultipleOf`, `WithScale`, `WithGranularity` — le pas est l'espace + d'entrée, et l'ancre diffère selon le type : c'est là que ça dérape. +* **Allers-retours.** Une valeur générée depuis un motif est reconnue par le vrai + moteur ; une URI générée s'analyse et porte la forme demandée. +* **Déterminisme.** Deux contextes de même graine concordent — pour *toute* graine, + pas pour 12345. +* **Composition.** `As`, `OrNull`, `Combine`, les viviers explicites : la valeur + composée porte la contrainte de chaque partie, quelles qu'aient été ces + contraintes. +* **Légalité dépendante de la valeur.** Quand le même appel est légal ou illégal + selon son argument, une propriété est la seule façon honnête de l'énoncer — se + ramifier sur la valeur, jamais sur la forme de l'appel. + +### Reste dans la suite par l'exemple + +* **Contenu des messages.** Un conflit doit nommer *les deux* contraintes fautives. + La formulation est sensible au sens d'application : affirmez-la sur un cas figé, + et ailleurs affirmez le **type** de l'exception. +* **Arguments nuls ou vides.** `null` n'a pas d'espace d'entrée. +* **Extrêmes nommés du domaine.** `int.MinValue`, `byte.MaxValue`, un vivier vide — + une coordonnée précise, plus claire et moins chère figée que quantifiée. +* **Atteignabilité.** Qu'une plage bornée soit effectivement atteinte, que les deux + branches d'un tirage soient observées. C'est statistique, non universel : figez + une graine. +* **Conventions structurelles.** Le miroir `Any` ↔ `AnyContext`, le nommage des + fabriques, la frontière d'assemblage autonome. De la réflexion sur une table + d'attentes fixe ; il n'y a rien à générer. +* **Régressions datées.** Un défaut qui a réellement eu lieu, figé aux coordonnées + où il a eu lieu. Une propriété couvrant le même terrain ne la retire **pas** — la + spécificité *est* la valeur. Référencez l'issue en commentaire. + +## Ajouter une fonctionnalité + +1. Écrivez d'abord les tests par l'exemple : c'est ainsi qu'on découvre la forme, et + ce sont eux qui portent les messages de conflit que votre nouvelle contrainte doit + produire. +2. Posez ensuite la question ci-dessus à chaque invariant écrit. Tout ce qui vaut + pour tout argument devient une propriété, et l'exemple qui en figeait un seul + disparaît avec. +3. Si votre contrainte interagit avec une contrainte existante, l'interaction est + presque toujours une propriété : elle a deux espaces d'entrée. + +## Corriger un défaut + +1. Figez le défaut en exemple, aux coordonnées où il a été trouvé, avec le numéro + d'issue en commentaire. C'est la régression, et elle reste pour toujours. +2. Demandez-vous ensuite si le défaut avait un espace d'entrée que l'exemple ne + couvre pas. L'issue #206 en avait un : le bug du milieu d'intervalle décimal a été + trouvé sur un intervalle et vivait sur tous. Ajoutez aussi la propriété. +3. Les deux atterrissent. La régression prouve le cas exact ; la propriété prouve la + classe. + +## Écrire la propriété + +Utilisez les helpers partagés de `PropertyTestSupport.cs` : + +* `Generators.OrderedPair(values)` — un `(min, max)` bien formé, paires dégénérées + comprises. Les intervalles épinglés sont un coin historiquement fragile : ne les + filtrez pas, ramifiez-vous dessus. +* `Generators.WithEdges(values, edges)` — les générateurs numériques de FsCheck sont + bornés en taille et se massent près de zéro : sans cela les extrémités du domaine + ne seraient pour ainsi dire jamais tirées. C'est précisément là que vit un décalage + d'une unité. +* `Expect.EveryDraw(generator, invariant)` — un générateur est une recette, pas une + valeur : un seul tirage par cas ne teste presque rien de son aléa. Huit tirages par + cas, sur cent cas, est la valeur par défaut. +* `Expect.Draws(generator, count)` — quand la propriété raisonne sur un lot + (distinction, atteignabilité) plutôt que sur chaque valeur isolément. + +Les règles qui gardent une propriété honnête : + +* **Affirmez les types d'exception, jamais le texte des messages.** Les messages sont + sensibles au sens d'application et changeront ; cette assertion appartient à un + exemple. +* **Sachez quand l'exception est levée.** Les conflits sont levés à *l'appel* fluide, + pas à `Generate()`. La validation des arguments précède la détection de conflit et + l'emporte quand les deux s'appliqueraient. +* **Gardez les coins dégénérés** que vos arguments générés peuvent produire : + intervalle vide, intervalle épinglé, cardinalité nulle, vivier épuisé. Soit le + générateur les évite, soit le prédicat s'y ramifie. +* **Figez une graine pour tout ce qui est statistique.** « Les deux moitiés sont + atteintes », « `null` finit par être tiré » sont probabilistes. Sous graine figée + elles sont déterministes ; sans elle, elles deviennent instables. Dites-le en + commentaire. +* **Restez rapide.** Cent cas fois huit tirages font déjà huit cents tirages. Plafonnez + longueurs et cardinalités dans les dizaines, pas les milliers. + +## Avant de pousser + +* `dotnet test JustDummies.PropertyTests` et `dotnet test JustDummies.UnitTests`. +* Les segments du plancher, que la CI exécute et qu'un `dotnet test` ordinaire ignore : + `dotnet build JustDummies.PropertyTests -c Release -f net472 -p:EnableNet472Floor=true`. + Tout ce qui utilise une API .NET 8+ appartient à `ModernTypeInvariantProperties.cs`, + que le fichier projet exclut de ce segment. +* **Vérifiez que votre propriété peut échouer.** Une propriété qui rend `true` pour la + mauvaise raison passe en silence et ne prouve rien. Cassez-la volontairement une fois + — inversez la comparaison, retirez une borne — constatez qu'elle rougit, puis + remettez-la. C'est la seule défense bon marché contre une suite verte parce qu'elle + n'affirme rien. diff --git a/doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md b/doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md new file mode 100644 index 00000000..2dadb958 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md @@ -0,0 +1,199 @@ +# ADR-0040 | Répartir le banc de test de JustDummies entre une suite par l'exemple et une suite par propriétés + +🌍 🇬🇧 [English](0040-split-the-justdummies-test-bed-between-example-and-property-suites.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-26 +**Décideurs :** Reefact + +## Contexte + +`JustDummies` construit des valeurs arbitraires qui satisfont les contraintes +déclarées. Son affirmation de correction est donc quantifiée universellement : +*chaque* valeur produite par un générateur satisfait *chacune* des contraintes +déclarées sur lui, pour *chaque* combinaison légale d'arguments de contrainte. + +Jusqu'ici cette affirmation était prouvée par une seule suite, +`JustDummies.UnitTests`, qui l'établit par échantillonnage. Un test fixe une +contrainte — `Between(10, 20)`, `WithLength(12)`, `WithCount(4)` — puis tire +quelques centaines de valeurs et vérifie l'invariant sur chacune. Les valeurs +tirées varient ; les **arguments de contrainte, non**. La suite instancie donc +l'affirmation universelle en une poignée de points choisis à la main et ne prouve +rien sur le reste de l'espace des contraintes. + +Des défauts ont déjà été trouvés dans cet espace non prouvé. L'issue #206 était un +générateur d'intervalle décimal dont les tirages ne franchissaient jamais le milieu +de la plage demandée : tous les candidats tombaient dans la moitié basse. Il a été +trouvé à la main et figé en régression sur un seul intervalle, `[0, 100]`. Le bug +vivait dans la relation entre des bornes arbitraires et la valeur produite — +précisément la dimension qu'un argument fixe ne peut pas faire varier. + +La suite tire par ailleurs ses propres valeurs échantillonnées depuis +`JustDummies`, si bien que le composant sous test participe à décider avec quoi il +est testé. + +Le dépôt exploite déjà des suites par propriétés. `FirstClassErrors.PropertyTests` +et `FirstClassErrors.RequestBinder.PropertyTests` utilisent FsCheck, portent le +segment du plancher .NET Framework 4.7.2, et sont ce que le contrôle *Fuzzing* de +l'OpenSSF Scorecard lit pour créditer le projet. Aucune des deux n'a été introduite +par un ADR : un projet frère `*.PropertyTests` est ici une pratique établie, non un +mouvement architectural nouveau. + +Tous les contrats de la bibliothèque ne sont pas quantifiés universellement. Un +conflit doit lever `ConflictingAnyConstraintException` avec un message nommant *les +deux* contraintes fautives ; un argument nul doit lever `ArgumentNullException` ; le +miroir entre `Any` et `AnyContext`, la convention de nommage des fabriques et la +frontière d'assemblage autonome de la bibliothèque sont des faits structurels +vérifiés par réflexion. Ce sont des cas spécifiques et nommés, et leur formulation +est délibérément sensible au sens de l'application — une propriété qui les +quantifierait affirmerait moins, et moins lisiblement. + +L'audit d'architecture de juillet 2026 a relevé que l'ADR-0025 cite « a property +test » contre le vrai moteur d'expressions régulières, alors que ce qui existe est +un test-oracle à graine fixe et corpus fixe dans le projet de tests unitaires, et a +demandé que le texte dise ce qu'est réellement le filet de sécurité. + +## Décision + +`JustDummies` est testé par deux suites sœurs sous une frontière unique : +`JustDummies.PropertyTests` porte tout invariant quantifiable sur des **arguments +de contrainte générés**, et `JustDummies.UnitTests` porte tout contrat dont le sujet +est un cas spécifique et nommé — contenu des messages, validation des arguments, +conventions structurelles et régressions datées. + +## Justification + +L'affirmation de la bibliothèque est une quantification universelle : le test dont +la forme lui correspond est donc celui qui quantifie. Générer les arguments de +contrainte — bornes, longueurs, cardinalités, viviers et graines qu'un appelant +déclare — fait passer chaque test du statut d'instance de l'affirmation à celui de +l'affirmation elle-même, et déplace la recherche vers l'espace où #206 vivait +réellement. Tirer davantage de valeurs derrière un `Between(10, 20)` fixe n'en +explore rien. + +Un cadre de propriétés rapporte aussi les échecs différemment. Le rétrécissement +réduit un contre-exemple à sa forme minimale : un défaut arrive donc sous la forme +du plus petit intervalle et de la plus petite valeur qui le déclenchent, plutôt que +comme un tirage opaque parmi quelques centaines. Pour un composant dont les échecs +sont des cas limites arithmétiques, c'est la différence entre un diagnostic et un +point de départ. + +Tirer les contraintes depuis un générateur indépendant brise la circularité relevée +dans le Contexte : la suite n'utilise plus `JustDummies` pour décider avec quoi +tester `JustDummies`. Le biais propre de FsCheck vers les petites valeurs est +compensé en y mêlant explicitement les bords du domaine, sans quoi un décalage d'une +unité à `int.MaxValue` ne serait pour ainsi dire jamais tiré. + +La frontière est tracée là où chaque style est réellement le plus fort, non par +nature de code. Le contenu des messages, le traitement des nuls et les gardes de +convention par réflexion ne sont pas des affirmations universelles ; les exprimer en +propriétés ajouterait une quantification sur des entrées qui ne varient pas, +brouillerait ce qui est affirmé, et rendrait les assertions sur le libellé exact plus +difficiles à lire. Les garder dans la suite par l'exemple laisse chaque suite dire ce +qu'elle dit le mieux, et fait que la localisation d'un échec indique déjà quelle +classe de contrat a cédé. + +Les régressions datées restent avec les exemples pour la même raison. Une régression +fige un défaut qui a réellement eu lieu, aux coordonnées où il a eu lieu ; cette +spécificité est sa valeur, et une propriété couvrant le même terrain ne la retire pas. + +Deux projets frères plutôt qu'un projet mixte suit la convention que le dépôt applique +déjà deux fois, garde la dépendance FsCheck hors de la suite qui n'en a pas besoin, et +laisse chaque projet énoncer sa propre histoire de plancher applicatif. + +## Alternatives considérées + +### Élargir les boucles d'échantillonnage de la suite existante + +Augmenter le nombre d'échantillons et ajouter davantage d'intervalles choisis à la +main est le changement le moins coûteux et ne demande aucun nouveau projet. + +Rejeté : cela multiplie les tirages à l'intérieur des mêmes arguments de contrainte +fixes. La dimension laissée inexplorée — la relation entre une borne arbitraire et la +valeur produite — le reste, de sorte que la classe de défaut à laquelle #206 +appartenait demeure invisible. On achète du temps d'exécution, pas de l'information. + +### Convertir tout le banc de test en propriétés + +Une suite unique est plus simple à expliquer, et les tests de forme invariante +gagneraient tous à être quantifiés. + +Rejeté : les contrats décrits dans le Contexte ne sont pas quantifiés +universellement. Une propriété affirmant qu'un message de conflit nomme les deux +contraintes est un moins bon test par l'exemple — elle ne quantifie sur rien tout en +rendant l'assertion plus difficile à lire — et les gardes de convention par réflexion +n'ont aucun espace d'entrée. + +### Héberger les propriétés dans `JustDummies.UnitTests` + +Un seul projet, c'est une seule chose à construire, exécuter et configurer. + +Rejeté : cela place deux styles d'assertion et deux jeux de dépendances dans un même +assemblage, et perd le signal que le nom d'un projet en échec porte déjà. Cela +s'écarterait aussi de la convention de projet frère que le dépôt a appliquée à +`FirstClassErrors` et `FirstClassErrors.RequestBinder`. + +### Générer les arguments de contrainte avec `JustDummies` lui-même + +La bibliothèque est un générateur de valeurs : elle pourrait fournir ses propres +entrées de test et éviter une dépendance. + +Rejeté : cela approfondit la circularité au lieu de la briser. Un défaut du générateur +serait alors libre de biaiser les entrées mêmes censées l'exposer, et aucun échec ne +pourrait être attribué avec confiance. + +## Conséquences + +### Positives + +* L'affirmation universelle de la bibliothèque est prouvée sur un espace de contraintes + plutôt qu'en une poignée de points, et les défauts de la classe de #206 deviennent + atteignables par la suite. +* Une propriété en échec arrive rétrécie à un contre-exemple minimal. +* Chaque suite énonce une seule sorte de contrat : la localisation d'un échec le classe + déjà. +* La suite par propriétés porte le segment du plancher .NET Framework 4.7.2 comme ses + sœurs, de sorte que les invariants sont prouvés contre l'asset `netstandard2.0` que + les consommateurs chargent réellement. +* L'aller-retour sur les expressions régulières est prouvé par une véritable propriété, + ce qui permet de reformuler exactement l'affirmation de l'ADR-0025 : le constat + d'audit qui a motivé ce point est clos par construction plutôt que par une réécriture. + +### Négatives + +* Deux suites sont à garder en tête quand un générateur change, et la frontière doit + être appliquée délibérément plutôt que par habitude. +* Un invariant déjà prouvé par une propriété peut malgré tout être réaffirmé par un + exemple qui paraît redondant lu isolément ; la redondance est voulue quand l'exemple + est une régression datée, et non voulue sinon. +* Les générateurs par défaut de FsCheck demandent un biaisage explicite vers les bords + pour être utiles ici, ce qui fait du code de support de test supplémentaire à maintenir. + +### Risques + +* Une propriété dont les arguments générés chevauchent une frontière de légalité + dépendante de la valeur (ADR-0035) peut être écrite de façon à échouer par + intermittence plutôt que de manière déterministe. Une propriété doit décider du + résultat attendu à partir de la valeur générée, non de la forme de l'appel. +* Les propriétés statistiques — qu'une plage soit atteinte, que les deux branches d'un + tirage à pile ou face soient observées — sont probabilistes, non universelles. Écrites + sans soin elles deviennent instables ; elles relèvent d'une graine figée et doivent + être signalées comme gardes statistiques. +* Élaguer la suite par l'exemple à mesure que les propriétés arrivent peut réduire + silencieusement la couverture si l'on retire un exemple dont la propriété ne subsume + pas réellement l'invariant. + +## Actions de suivi + +* Réexaminer la formulation « property test » de l'ADR-0025, que l'audit a signalée comme + inexacte, maintenant qu'une propriété d'aller-retour existe. Seul `@reefact` peut + amender ou remplacer un ADR accepté. + +## Références + +* [Écrire les tests de JustDummies](../WritingJustDummiesTests.fr.md) — comment cette frontière s'applique au moment d'ajouter un test +* [ADR-0025](0025-generate-strings-from-a-home-grown-regular-subset.fr.md) — le sous-ensemble régulier dont cette suite prouve l'aller-retour +* [ADR-0035](0035-enforce-structural-any-conflicts-at-compile-time.fr.md) — conflits structurels contre conflits dépendants de la valeur, qui décident de la façon dont une propriété doit se ramifier +* [ADR-0036](0036-draw-lattice-constrained-scalars-on-the-grid.fr.md) — les contraintes de treillis, dont l'invariant de grille est quantifié par la suite par propriétés +* [Audit d'architecture et de conception de JustDummies, 2026-07-20](../audit/2026-07-20-dummies-architecture-and-design-audit.fr.md) — le constat « non property-based » sur l'ADR-0025 +* Issue #206 — le défaut d'intervalle décimal qui a motivé la quantification sur les bornes diff --git a/doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.md b/doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.md new file mode 100644 index 00000000..3af53c92 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.md @@ -0,0 +1,185 @@ +# ADR-0040 | Split the JustDummies test bed between an example suite and a property suite + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md) + +**Status:** Accepted +**Date:** 2026-07-26 +**Decision Makers:** Reefact + +## Context + +`JustDummies` builds arbitrary values that satisfy declared constraints. Its +correctness claim is therefore universally quantified: *every* value a generator +produces satisfies *every* constraint declared on it, for *every* legal +combination of constraint arguments. + +Until now that claim was proven by a single suite, `JustDummies.UnitTests`, which +establishes it by sampling. A test pins a constraint — `Between(10, 20)`, +`WithLength(12)`, `WithCount(4)` — then draws a few hundred values and asserts the +invariant on each. The drawn values vary; the **constraint arguments do not**. The +suite therefore instantiates the universal claim at a handful of hand-picked +points and proves nothing about the rest of the constraint space. + +Defects have already been found in that unproven space. Issue #206 was a decimal +interval generator whose draws never crossed the midpoint of the requested range: +every candidate fell in the lower half. It was found by hand and pinned as a +seeded regression over one interval, `[0, 100]`. The bug lived in the relation +between arbitrary bounds and the produced value — precisely the dimension a fixed +argument cannot vary. + +The suite also draws its own sampled values from `JustDummies`, so the component +under test participates in deciding what it is tested with. + +The repository already operates property-based suites. `FirstClassErrors.PropertyTests` +and `FirstClassErrors.RequestBinder.PropertyTests` run FsCheck, carry the .NET +Framework 4.7.2 floor leg, and are what OpenSSF Scorecard's Fuzzing check reads to +credit the project. Neither was introduced by an ADR: a sibling `*.PropertyTests` +project is established practice here, not a new architectural move. + +Not every contract of the library is universally quantified. A conflict must raise +`ConflictingAnyConstraintException` with a message naming *both* offending +constraints; a null argument must raise `ArgumentNullException`; the mirror between +`Any` and `AnyContext`, the factory naming convention, and the library's standalone +assembly boundary are structural facts checked by reflection. These are specific, +named cases, and their wording is deliberately direction-aware — a property that +quantified over them would assert less, less readably. + +The July 2026 architecture audit recorded that ADR-0025 cites "a property test" +against the real regular-expression engine, whereas what exists is a fixed-seed, +fixed-corpus oracle test in the unit-test project, and asked that the text say what +the safety net actually is. + +## Decision + +`JustDummies` is tested by two sibling suites under one boundary: `JustDummies.PropertyTests` +owns every invariant that can be quantified over **generated constraint arguments**, +and `JustDummies.UnitTests` owns every contract whose subject is a specific, named +case — message content, argument validation, structural conventions, and dated +regressions. + +## Rationale + +The library's claim is a universal quantification, so the test that matches its +shape is one that quantifies. Generating the constraint arguments — the bounds, +lengths, counts, pools and seeds a caller declares — turns each test from an +instance of the claim into the claim itself, and moves the search into the space +where #206 actually lived. Sampling more values behind a fixed `Between(10, 20)` +explores none of it. + +A property framework also reports failures differently. Shrinking reduces a +counter-example to its minimal form, so a defect arrives as the smallest interval +and value that break it rather than as one opaque draw out of a few hundred. For a +component whose failures are arithmetic edge cases, that is the difference between +a diagnosis and a starting point. + +Drawing the constraints from an independent generator breaks the circularity noted +in Context: the suite no longer uses `JustDummies` to decide what to test +`JustDummies` with. FsCheck's own bias toward small values is compensated by +explicitly mixing in domain edges, since an off-by-one at `int.MaxValue` is +otherwise almost never drawn. + +The boundary is drawn where each style is genuinely stronger, not by kind of code. +Message content, null handling and reflection-driven conventions are not universal +claims; expressing them as properties would add quantification over inputs that do +not vary, obscure what is being asserted, and make the exact-wording assertions +harder to read. Keeping them in the example suite leaves each suite saying the kind +of thing it says best, and lets a failure's location already indicate what class of +contract broke. + +Dated regressions stay with the examples for the same reason. A regression pins a +defect that actually occurred, at the coordinates where it occurred; that specificity +is its value, and a property covering the same ground does not retire it. + +Two sibling projects rather than one mixed project follows the convention the +repository already applies twice, keeps the FsCheck dependency out of the suite that +does not need it, and lets each project state its own framework-floor story. + +## Alternatives Considered + +### Widen the sampled loops in the existing suite + +Raising the sample count and adding more hand-picked intervals is the cheapest +change and needs no new project. + +Rejected: it multiplies draws inside the same fixed constraint arguments. The +dimension left unexplored — the relation between an arbitrary bound and the produced +value — stays unexplored, so the class of defect that #206 belonged to remains +invisible. It buys runtime, not information. + +### Convert the whole test bed to properties + +A single suite is simpler to explain, and the invariant-shaped tests would all gain +from quantification. + +Rejected: the contracts described in Context are not universally quantified. A +property asserting that a conflict message names both constraints is a worse example +test — it quantifies over nothing while making the assertion harder to read — and the +reflection-driven convention guards have no input space at all. + +### Host the properties inside `JustDummies.UnitTests` + +One project is one thing to build, run and configure. + +Rejected: it puts two assertion styles and two dependency sets in one assembly, and +loses the signal that a failing project name already carries. It would also depart +from the sibling-project convention the repository has applied to `FirstClassErrors` +and `FirstClassErrors.RequestBinder`. + +### Generate the constraint arguments with `JustDummies` itself + +The library is a value generator, so it could supply its own test inputs and avoid a +dependency. + +Rejected: it deepens the circularity rather than breaking it. A generator defect +would then be free to bias the very inputs meant to expose it, and no failure could +be attributed with confidence. + +## Consequences + +### Positive + +* The library's universal claim is proven over a constraint space rather than at a + handful of points, and defects of the #206 class become reachable by the suite. +* A failing property arrives shrunk to a minimal counter-example. +* Each suite states one kind of contract, so a failure's location already classifies it. +* The property suite carries the .NET Framework 4.7.2 floor leg like its siblings, so + the invariants are proven against the `netstandard2.0` asset consumers actually load. +* The regular-expression round-trip is proven by an actual property, which lets ADR-0025's + claim be restated accurately — the audit finding that prompted this is closed by + construction rather than by editing the text. + +### Negative + +* Two suites must be kept in mind when a generator changes, and the boundary has to be + applied deliberately rather than by habit. +* An invariant already proven by a property may still be re-asserted by an example that + looks redundant when read in isolation; the redundancy is intended where the example + is a dated regression, and unintended otherwise. +* FsCheck's default generators need explicit edge-biasing to be useful here, which is + additional test-support code to maintain. + +### Risks + +* A property whose generated arguments straddle a value-dependent legality boundary + (ADR-0035) can be written so that it fails intermittently rather than deterministically. + A property must decide the expected outcome from the generated value, not from the call + shape. +* Statistical properties — that a range is reached, that both branches of a coin flip are + observed — are probabilistic, not universal. Written carelessly they flake; they belong + under a pinned seed and must be labelled as statistical guards. +* Pruning the example suite as properties land can silently reduce coverage if an example + is removed whose invariant the property does not in fact subsume. + +## Follow-up Actions + +* Revisit ADR-0025's "property test" wording, which the audit flagged as inaccurate, now that + a round-trip property exists. Only `@reefact` may amend or supersede an accepted ADR. + +## References + +* [Writing JustDummies tests](../WritingJustDummiesTests.en.md) — how this boundary is applied when adding a test +* [ADR-0025](0025-generate-strings-from-a-home-grown-regular-subset.md) — the regular subset whose round-trip this suite proves +* [ADR-0035](0035-enforce-structural-any-conflicts-at-compile-time.md) — structural versus value-dependent conflicts, which decides how a property must branch +* [ADR-0036](0036-draw-lattice-constrained-scalars-on-the-grid.md) — lattice constraints, whose grid invariant is quantified by the property suite +* [JustDummies architecture and design audit, 2026-07-20](../audit/2026-07-20-dummies-architecture-and-design-audit.md) — the "not property-based" finding on ADR-0025 +* Issue #206 — the decimal interval defect that motivated quantifying over bounds diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index b837e33f..623dbc8a 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -222,3 +222,4 @@ Optional supporting material: | [ADR-0037](0037-vary-the-datetimeoffset-offset-dimension.md) | Vary the DateTimeOffset offset dimension | Accepted | | [ADR-0038](0038-open-the-ambient-seed-scope-to-adapters.md) | Open the ambient seed scope to test-framework adapters | Accepted | | [ADR-0039](0039-adapt-dummies-to-xunit-v3-through-a-companion-package.md) | Adapt JustDummies to xUnit v3 through a companion package | Accepted | +| [ADR-0040](0040-split-the-justdummies-test-bed-between-example-and-property-suites.md) | Split the JustDummies test bed between an example suite and a property suite | Accepted |