From 8003b7bf23c647e6dca8b05ba80351931a7c8f43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 16:25:03 +0000 Subject: [PATCH 1/7] test(justdummies): add a property-based test project The example-based suite pins hand-picked constraint arguments -- Between(10, 20), WithLength(12) -- and can only prove a generator right for those. The sampled loops it wraps them in are property tests in spirit, but the constraint space itself stays fixed, so a bound that overflows for one interval in a million is never drawn. Issue #206 was exactly that: a decimal interval whose draws never left the lower half, found by hand and pinned as a seeded regression. JustDummies.PropertyTests inverts the split. FsCheck generates the CONSTRAINTS -- the bounds, lengths, counts and seeds a caller declares -- while JustDummies generates the value that must satisfy them, and the property asserts the invariant over the whole space. A failure shrinks to its minimal counter-example instead of surfacing as an opaque sample. Drawing the constraints from FsCheck also breaks a circularity, since the suite no longer uses the component under test to decide what to test it with. The project mirrors its siblings: JustDummies only, so the library's standalone boundary stays visible in its own test bed, and the net472 floor leg so the invariants are proven against the netstandard2.0 asset .NET Framework consumers actually load. This commit carries the scaffold, the shared constraint generators, and the Int32 interval family as the exemplar the remaining families follow. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019v9nNWTjPcLdv3K54adsWr --- .github/workflows/ci.yml | 6 +- FirstClassErrors.sln | 19 ++- .../Int32IntervalProperties.cs | 130 +++++++++++++++++ .../JustDummies.PropertyTests.csproj | 49 +++++++ .../PropertyTestSupport.cs | 135 ++++++++++++++++++ 5 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 JustDummies.PropertyTests/Int32IntervalProperties.cs create mode 100644 JustDummies.PropertyTests/JustDummies.PropertyTests.csproj create mode 100644 JustDummies.PropertyTests/PropertyTestSupport.cs 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/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/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/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 + +} From 249e95da814e594a8ed47c39b9247eeda41876d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 16:52:47 +0000 Subject: [PATCH 2/7] docs: record the JustDummies example/property test split as ADR-0040 JustDummies claims that every value it builds satisfies every constraint declared on it, for every legal combination of constraint arguments -- a universal quantification. The example suite proves that claim by sampling: it pins the constraint arguments and varies only the drawn values, so it instantiates the claim at a handful of hand-picked points. Issue #206 lived in the dimension that leaves unexplored, the relation between an arbitrary bound and the produced value. Adding a sibling *.PropertyTests project is established practice here and needed no record on its own -- neither existing one has an ADR. What is new and lasting is the BOUNDARY, and the fact that tests are actively moved out of the example suite: properties own invariants quantified over generated constraint arguments, examples own message content, argument validation, structural conventions and dated regressions. That rule is what a future maintainer will question when the same bound appears tested in two places, and it survives any reorganization of the files themselves. Drafted as Proposed and indexed; only the maintainer accepts an ADR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019v9nNWTjPcLdv3K54adsWr --- ...-between-example-and-property-suites.fr.md | 201 ++++++++++++++++++ ...bed-between-example-and-property-suites.md | 186 ++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 3 files changed, 388 insertions(+) create mode 100644 doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.md 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..37d4be5a --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md @@ -0,0 +1,201 @@ +# 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 :** Proposé +**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 + +* Publier la frontière sous forme de guide mainteneur, afin qu'un contributeur ajoutant + une contrainte ou corrigeant un défaut puisse placer son test sans re-dériver cette + décision. +* 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 + +* [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..f30134f7 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0040-split-the-justdummies-test-bed-between-example-and-property-suites.md @@ -0,0 +1,186 @@ +# 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:** Proposed +**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 + +* Publish the boundary as a maintainer guide so a contributor adding a constraint or fixing + a defect can place the test without re-deriving this decision. +* 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 + +* [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..dc5a0f01 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 | Proposed | From 3c9aff4e1e4018e31221edb3c3ebe0d45e2c6daf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 16:54:11 +0000 Subject: [PATCH 3/7] test(justdummies): add scalar, continuous, lattice and string properties Four families following the Int32 exemplar, each quantifying over generated constraint arguments rather than hand-picked ones. Scalars cover the integer widths other than Int32, with the invariant set spread so every one is proven on at least one signed and one unsigned width. Continuous covers Double, Single and Decimal, including the both-halves reachability that issue #206 violated -- now asserted over arbitrary intervals instead of the single [0, 100] the seeded regression pins. Lattice covers MultipleOf, WithScale and WithGranularity, whose per-type anchor (zero, DateTime.MinValue, DateTimeOffset.MinValue) is the classic place to get the grid wrong. String covers the length, affix, charset and casing algebra, including the value-dependent legality ADR-0035 describes: WithLength(n) with StartingWith(prefix) is valid exactly when n is at least the prefix length, and the property branches on that relation rather than assuming either outcome. 87 properties pass on net10.0; the project also compiles on the net472 floor. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019v9nNWTjPcLdv3K54adsWr --- .../ContinuousIntervalProperties.cs | 355 +++++++++++++++++ .../LatticeConstraintProperties.cs | 352 +++++++++++++++++ .../ScalarIntervalProperties.cs | 301 +++++++++++++++ .../StringShapeProperties.cs | 360 ++++++++++++++++++ 4 files changed, 1368 insertions(+) create mode 100644 JustDummies.PropertyTests/ContinuousIntervalProperties.cs create mode 100644 JustDummies.PropertyTests/LatticeConstraintProperties.cs create mode 100644 JustDummies.PropertyTests/ScalarIntervalProperties.cs create mode 100644 JustDummies.PropertyTests/StringShapeProperties.cs 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/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/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/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(); + } + +} From dd57da2a356325ab65d050abab900c4c4adcb7fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 17:41:38 +0000 Subject: [PATCH 4/7] test(justdummies): complete the property suite Completes the property suite at 210 properties across twelve families. The regular-expression round trip is the centrepiece: patterns are generated recursively over the supported subset -- nested alternation, named and non-capturing groups, negated classes, ranges, lazy and bounded quantifiers, \x and \u escapes -- and every value drawn from one is checked against the real .NET engine as oracle. Sampling the generator shows 100 distinct patterns per run averaging ~82 characters, so this fuzzes the home-grown engine rather than re-pinning a corpus. ADR-0025's safety net is now literally a property test, which is what the July audit asked for. Seeds are quantified rather than hand-picked: the example suite pins 12345, 777 and 31415, which says nothing about seeds in general. Collections quantify the count algebra including the eager cardinality conflict, where a distinct count exceeding what the element generator advertises must be refused at declaration time. Composition covers As, OrNull, Combine and the explicit pools, including that ElementOf materializes a lazy source exactly once. Temporal covers the offset dimension over arbitrary whole-minute offsets within the legal range. Modern types cover the net8-only generators and are excluded from the net472 leg by the project file. Two draws assembled a 64-bit word by or-ing shifted fields, which the compiler reads as or-ing a sign-extended operand (CS0675). The fields are disjoint, so they are summed instead: same word, no warning. 210 properties pass on net10.0 with no warnings; the suite also compiles on the net472 floor. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019v9nNWTjPcLdv3K54adsWr --- .../CollectionProperties.cs | 284 +++++++++ .../CompositionProperties.cs | 446 ++++++++++++++ .../ModernTypeInvariantProperties.cs | 440 ++++++++++++++ .../PatternRoundTripProperties.cs | 566 ++++++++++++++++++ .../SeedDeterminismProperties.cs | 360 +++++++++++ .../TemporalProperties.cs | 480 +++++++++++++++ JustDummies.PropertyTests/UriProperties.cs | 511 ++++++++++++++++ 7 files changed, 3087 insertions(+) create mode 100644 JustDummies.PropertyTests/CollectionProperties.cs create mode 100644 JustDummies.PropertyTests/CompositionProperties.cs create mode 100644 JustDummies.PropertyTests/ModernTypeInvariantProperties.cs create mode 100644 JustDummies.PropertyTests/PatternRoundTripProperties.cs create mode 100644 JustDummies.PropertyTests/SeedDeterminismProperties.cs create mode 100644 JustDummies.PropertyTests/TemporalProperties.cs create mode 100644 JustDummies.PropertyTests/UriProperties.cs 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/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/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/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(); + } + +} From e348cd1b5458aee345ab80814b2398ee71aebaa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 17:47:38 +0000 Subject: [PATCH 5/7] test(justdummies): move quantifiable invariants out of the example suite Sixteen tests asserted an invariant that holds for every legal constraint argument while pinning exactly one: Between containment and pinning, bound strictness and inclusiveness, OneOf and Except membership, recipe immutability, crossed-argument rejection, non-finite argument rejection, and seed agreement pinned to 12345, 777 and 31415. Each is now quantified over generated arguments in JustDummies.PropertyTests, which proves strictly more, so keeping the pinned version would assert a special case of a property one file away (ADR-0040). Removed only what a property strictly subsumes. Everything whose subject is a specific named case stays: conflict messages, which name both offending constraints in direction-aware wording; null and empty argument guards; the named domain extremes; that a bounded range is actually reached, which is statistical rather than universal; and the seeded regression for issue #206, which pins the interval where the defect occurred and is not retired by the property covering the class. Nothing was removed from the signed, unsigned or remaining suites: their methods interleave a sampled invariant with a conflict-message assertion, so removing the loop would take the assertion with it. Each pruned class now carries a note saying which half of its contract it keeps, so the absence reads as a decision rather than an oversight. JustDummies.UnitTests 535 -> 519; JustDummies.PropertyTests adds 210. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019v9nNWTjPcLdv3K54adsWr --- JustDummies.UnitTests/AnyContinuousTests.cs | 24 ++----- JustDummies.UnitTests/AnyInt32Tests.cs | 71 ++----------------- .../SeedReproducibilityTests.cs | 71 ++----------------- 3 files changed, 20 insertions(+), 146 deletions(-) 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; From 9c50ba0e750f2ca0132eabe8816bb3f69ccf8182 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 17:47:42 +0000 Subject: [PATCH 6/7] docs: publish the JustDummies test-placement guide ADR-0040 records WHY the test bed is split; a contributor adding a constraint or fixing a defect needs the HOW. The guide reduces the boundary to one question -- does the assertion have an input space? -- and applies it: bounds, lengths, counts, seeds and patterns are input spaces and belong to a property; message wording, null arguments, named domain extremes, structural conventions and dated regressions are not, and stay examples. It also carries the two disciplines that keep such a suite honest and that no ADR would hold: pin a seed for anything statistical, since "both halves are reached" is probabilistic and flakes without one; and break each property on purpose once to confirm it can go red, since a property returning true for the wrong reason passes silently and proves nothing. Indexed in the maintainer README and pointed to from CONTRIBUTING, CLAUDE and AGENTS, so it is found before a test is written rather than after -- the same reason the solution-nesting reminder was added to those files. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019v9nNWTjPcLdv3K54adsWr --- AGENTS.md | 6 + CLAUDE.md | 7 + CONTRIBUTING.md | 4 + doc/handwritten/for-maintainers/README.fr.md | 9 ++ doc/handwritten/for-maintainers/README.md | 8 + .../WritingJustDummiesTests.en.md | 128 ++++++++++++++++ .../WritingJustDummiesTests.fr.md | 139 ++++++++++++++++++ ...-between-example-and-property-suites.fr.md | 4 +- ...bed-between-example-and-property-suites.md | 3 +- 9 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md create mode 100644 doc/handwritten/for-maintainers/WritingJustDummiesTests.fr.md 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/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 index 37d4be5a..e2be9520 100644 --- 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 @@ -185,15 +185,13 @@ pourrait être attribué avec confiance. ## Actions de suivi -* Publier la frontière sous forme de guide mainteneur, afin qu'un contributeur ajoutant - une contrainte ou corrigeant un défaut puisse placer son test sans re-dériver cette - décision. * 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 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 index f30134f7..e1ba32ea 100644 --- 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 @@ -172,13 +172,12 @@ be attributed with confidence. ## Follow-up Actions -* Publish the boundary as a maintainer guide so a contributor adding a constraint or fixing - a defect can place the test without re-deriving this decision. * 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 From bee8c2c76d3b130435aceaf1eb75fddd42562025 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 18:02:44 +0000 Subject: [PATCH 7/7] docs: accept ADR-0040 (example/property test split) ADR-0040 ("Split the JustDummies test bed between an example suite and a property suite") records the boundary the property suite and the pruning of the example suite implement. The maintainer accepts the decision; the Date stays 2026-07-26, the day it took effect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019v9nNWTjPcLdv3K54adsWr --- ...stdummies-test-bed-between-example-and-property-suites.fr.md | 2 +- ...-justdummies-test-bed-between-example-and-property-suites.md | 2 +- doc/handwritten/for-maintainers/adr/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 index e2be9520..2dadb958 100644 --- 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 @@ -2,7 +2,7 @@ 🌍 🇬🇧 [English](0040-split-the-justdummies-test-bed-between-example-and-property-suites.md) · 🇫🇷 Français (ce fichier) -**Statut :** Proposé +**Statut :** Accepté **Date :** 2026-07-26 **Décideurs :** Reefact 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 index e1ba32ea..3af53c92 100644 --- 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 @@ -2,7 +2,7 @@ 🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md) -**Status:** Proposed +**Status:** Accepted **Date:** 2026-07-26 **Decision Makers:** Reefact diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index dc5a0f01..623dbc8a 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -222,4 +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 | Proposed | +| [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 |