diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12fc2263..bec83446 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: framework-floor: name: Library on the .NET Framework 4.7.2 floor runs-on: windows-latest - # Restore + build + test of three small projects; cap a hung run like the other jobs. + # Restore + build + test of four small projects; cap a hung run like the other jobs. timeout-minutes: 15 steps: - name: Checkout @@ -95,11 +95,13 @@ jobs: with: dotnet-version: '10.0.x' - # Only the three library test projects carry the net472 leg (EnableNet472Floor adds it): the tooling test + # Only these library test projects carry the net472 leg (EnableNet472Floor adds it): the tooling test # projects (Roslyn / GenDoc / Cli) stay net10-only by design, and RequestBinder.UnitTests stays net10-only # because its fixtures bind DateOnly, a .NET 6+ type absent from net472. RequestBinder is still floored here - # through its property tests. 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 three. + # through its property tests, and Dummies through its own contract suite (Dummies.UnitTests), running on the + # netstandard2.0 asset .NET Framework consumers load — its net8-only tests are conditioned out (issue #215). + # 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 four. - name: Test the netstandard2.0 libraries on .NET Framework 4.7.2 shell: bash run: | @@ -107,7 +109,8 @@ jobs: for proj in \ FirstClassErrors.UnitTests \ FirstClassErrors.PropertyTests \ - FirstClassErrors.RequestBinder.PropertyTests ; do + FirstClassErrors.RequestBinder.PropertyTests \ + Dummies.UnitTests ; do echo "::group::$proj (net472)" dotnet test "$proj/$proj.csproj" -c Release -f net472 -p:EnableNet472Floor=true \ --logger "console;verbosity=normal" diff --git a/.github/workflows/dummies.yml b/.github/workflows/dummies.yml index 9bfa33aa..be02d3f2 100644 --- a/.github/workflows/dummies.yml +++ b/.github/workflows/dummies.yml @@ -90,3 +90,26 @@ jobs: cat netstandard.log grep -q 'ASSET=.NETStandard,Version=v2.0' netstandard.log grep -q 'RESULT=PASS' netstandard.log + + - name: Cross-TFM seed equality (net8.0 asset vs netstandard2.0 asset) + # Each leg above printed a SEEDBATCH= line: the SAME fixed seed drawn from the COMMON surface, each on its + # OWN runtime from the asset that consumer TFM forced. The two packaged assets must produce an identical + # seeded sequence — new Random(seed) keeps the legacy algorithm on modern .NET so they SHOULD agree, but + # nothing else asserts it and Random reserves the right to differ across framework versions (issue #215). + # Compare byte-for-byte; the empty-guard fails closed so a missing/renamed banner can never pass silently. + working-directory: tools/dummies-check + run: | + set -euo pipefail + net8_seq="$(sed -n 's/^SEEDBATCH=//p' net8.log)" + netstd_seq="$(sed -n 's/^SEEDBATCH=//p' netstandard.log)" + echo "net8.0 asset : ${net8_seq}" + echo "netstandard2.0 asset: ${netstd_seq}" + if [ -z "$net8_seq" ] || [ -z "$netstd_seq" ]; then + echo "FAIL: a SEEDBATCH banner was missing (net8.0='${net8_seq}', netstandard2.0='${netstd_seq}')" + exit 1 + fi + if [ "$net8_seq" != "$netstd_seq" ]; then + echo "FAIL: cross-TFM seed divergence — the two packaged assets produced different seeded sequences" + exit 1 + fi + echo "PASS: both assets produced an identical seeded sequence" diff --git a/Dummies.UnitTests/AnyCollectionTests.cs b/Dummies.UnitTests/AnyCollectionTests.cs index 1899b3a0..6b1e2de8 100644 --- a/Dummies.UnitTests/AnyCollectionTests.cs +++ b/Dummies.UnitTests/AnyCollectionTests.cs @@ -257,7 +257,9 @@ public void FiniteScalarGeneratorsGateEagerly() { Check.ThatCode(() => Any.SetOf(Any.Decimal().OneOf(1m, 2m)).WithCount(3)).Throws(); Check.ThatCode(() => Any.SetOf(Any.Double().OneOf(1d, 2d)).WithCount(3)).Throws(); Check.ThatCode(() => Any.SetOf(Any.Single().OneOf(1f, 2f)).WithCount(3)).Throws(); +#if NET8_0_OR_GREATER Check.ThatCode(() => Any.SetOf(Any.Int128().Between(1, 3)).WithCount(5)).Throws(); +#endif // Membership travels with cardinality: an out-of-domain contained value extends the effective domain... for (int i = 0; i < SampleCount; i++) { diff --git a/Dummies.UnitTests/Dummies.UnitTests.csproj b/Dummies.UnitTests/Dummies.UnitTests.csproj index 20a369fb..b5f65bc9 100644 --- a/Dummies.UnitTests/Dummies.UnitTests.csproj +++ b/Dummies.UnitTests/Dummies.UnitTests.csproj @@ -1,7 +1,12 @@ + + + - net10.0 enable enable false @@ -32,4 +37,16 @@ + + + + + + + diff --git a/Dummies.UnitTests/SeedReproducibilityTests.cs b/Dummies.UnitTests/SeedReproducibilityTests.cs index 3eae09a3..189dafa7 100644 --- a/Dummies.UnitTests/SeedReproducibilityTests.cs +++ b/Dummies.UnitTests/SeedReproducibilityTests.cs @@ -30,8 +30,10 @@ private static string Batch() { char letter = Any.Char().Generate(); TimeSpan span = Any.TimeSpan().Generate(); DateTime instant = Any.DateTime().Generate(); +#if NET8_0_OR_GREATER Int128 huge = Any.Int128().Generate(); Half tiny = Any.Half().Generate(); +#endif 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(); @@ -39,7 +41,10 @@ private static string Batch() { return string.Join("|", full, bounded, free, capped, shaped, wide, unsigned, real, exact, flag, id, letter, - span.Ticks, instant.Ticks, huge, tiny, + span.Ticks, instant.Ticks, +#if NET8_0_OR_GREATER + huge, tiny, +#endif string.Join("-", list), string.Join("-", set.OrderBy(value => value)), maybe?.ToString() ?? "null", coded); } diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index a26b2907..a7530422 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -29,7 +29,9 @@ matter — and that is the point. `Boolean`, `Guid`, `Enum` (declared members only), `TimeSpan`, `DateTime` (UTC) and `DateTimeOffset`. On modern targets (`net8.0`) the surface extends to `DateOnly`, `TimeOnly`, `Int128`, `UInt128` and `Half`; the package also targets - `netstandard2.0` for the widest reach. + `netstandard2.0` and runs on **.NET Framework 4.7.2+**, .NET Core 2.0+ and .NET 5+ + for the widest reach — with the .NET Framework 4.7.2 floor exercised in CI, not + merely advertised. - **Strings from a regex**: `Any.StringMatching(pattern)` generates arbitrary strings that match a regular expression — the dummy for a format-validated value object. Home-grown (zero dependencies) over the regular subset of the pattern language; a diff --git a/tools/dummies-check/Program.cs b/tools/dummies-check/Program.cs index 7dc4cbe7..ce5b5064 100644 --- a/tools/dummies-check/Program.cs +++ b/tools/dummies-check/Program.cs @@ -7,6 +7,7 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Text.RegularExpressions; using Dummies; @@ -37,6 +38,13 @@ internal static class Program { // asset, absent on the netstandard2.0 asset — the exact conditional surface the acceptance criteria name. private static readonly string[] ModernEntryPoints = { "DateOnly", "TimeOnly", "Int128", "UInt128", "Half" }; + // Fixed seed for the cross-TFM golden sequence. SeedBatch draws from the COMMON surface under this seed, and + // Main prints the result as the SEEDBATCH banner. dummies.yml compares that banner byte-for-byte between the + // net8.0 and netstandard2.0 legs: new Random(seed) keeps the legacy algorithm on modern .NET, so the two + // packaged assets SHOULD agree seed-for-seed — but nothing else asserts it, and Random reserves the right to + // differ across framework versions. This turns that silent assumption into a checked contract (issue #215). + private const int CrossTfmSeed = 20260719; + private static int Main() { Assembly dummies = typeof(Any).Assembly; string assetMoniker = dummies.GetCustomAttribute()?.FrameworkName ?? "(none)"; @@ -47,6 +55,11 @@ private static int Main() { Console.WriteLine($"ASSET={assetMoniker}"); Console.WriteLine($"RUNTIME={RuntimeInformation.FrameworkDescription}"); + // The seeded common-surface batch for THIS asset, on one grep-safe line (every draw renders to printable + // ASCII). dummies.yml diffs it against the other leg's banner to prove cross-asset seed equality. Emitted + // here with the other identifying banners, before the checks run; a leg that no-oped would omit it. + Console.WriteLine($"SEEDBATCH={SeedBatch(Any.WithSeed(CrossTfmSeed))}"); + List failures = new(); // 1. The restored asset is the one this consumer TFM is meant to force. @@ -111,10 +124,34 @@ private static void RunSmoke(List failures) { HashSet set = Any.SetOf(Any.Int32().Between(0, 99)).WithCount(3).Generate(); Require(failures, set.Count == 3, $"SetOf(...).WithCount(3) produced {set.Count} elements"); + // issue #215: exercise the common generators the packaged-asset guard never touched, so a break on either + // asset (a packaging or conditional-compilation regression) surfaces here — OrNull, array/sequence, + // pair/triple, StringMatching and enum draws. These also ride the SEEDBATCH cross-asset comparison below. + int? maybeDiscount = Any.Int32().Between(0, 100).OrNull().Generate(); + Require(failures, maybeDiscount is null or (>= 0 and <= 100), $"Int32().Between(0,100).OrNull() produced {maybeDiscount}"); + + int[] trio = Any.ArrayOf(Any.Int32().Between(0, 9)).WithCount(3).Generate(); + Require(failures, trio.Length == 3 && trio.All(value => value is >= 0 and <= 9), $"ArrayOf(...).WithCount(3) produced [{string.Join(",", trio)}]"); + + List couple = Any.SequenceOf(Any.Int32().Between(0, 9)).WithCount(2).Generate().ToList(); + Require(failures, couple.Count == 2 && couple.All(value => value is >= 0 and <= 9), $"SequenceOf(...).WithCount(2) produced {couple.Count} elements"); + + (int, string) pair = Any.PairOf(Any.Int32().Between(1, 9), Any.String().NonEmpty().WithMaxLength(4)).Generate(); + Require(failures, pair.Item1 is >= 1 and <= 9 && pair.Item2.Length is >= 1 and <= 4, $"PairOf(...) produced ({pair.Item1},{pair.Item2})"); + + (bool, int, char) triple = Any.TripleOf(Any.Boolean(), Any.Int32().Between(0, 9), Any.Char()).Generate(); + Require(failures, triple.Item2 is >= 0 and <= 9, $"TripleOf(...) produced ({triple.Item1},{triple.Item2},{triple.Item3})"); + + string code = Any.StringMatching("[A-Z]{3}-[0-9]{4}").Generate(); + Require(failures, Regex.IsMatch(code, "^[A-Z]{3}-[0-9]{4}$"), $"StringMatching('[A-Z]{{3}}-[0-9]{{4}}') produced '{code}'"); + + Suit suit = Any.Enum().Generate(); + Require(failures, System.Enum.IsDefined(typeof(Suit), suit), $"Enum() produced {suit}"); + // Seeded reproducibility: two contexts with the same seed replay an identical mixed sequence, and a // different seed diverges. This is the library's crown-jewel guarantee — verified here on each asset. - string first = SeedBatch(Any.WithSeed(20260719)); - string second = SeedBatch(Any.WithSeed(20260719)); + string first = SeedBatch(Any.WithSeed(CrossTfmSeed)); + string second = SeedBatch(Any.WithSeed(CrossTfmSeed)); Require(failures, first == second, "same-seed contexts diverged"); string other = SeedBatch(Any.WithSeed(987654321)); @@ -123,6 +160,8 @@ private static void RunSmoke(List failures) { // Draws a fixed mixed sequence from the COMMON surface only (no modern types), so it compiles and runs // on both assets. Rendered with InvariantCulture to match the library's own culture-invariant rendering. + // Every part renders to printable ASCII (unconstrained Char/String draw ASCII letters and digits; the + // pattern and enum are ASCII by construction), so the joined line is safe to emit as a one-line banner. private static string SeedBatch(AnyContext any) { List parts = new() { any.Int32().Generate().ToString(CultureInfo.InvariantCulture), @@ -139,6 +178,23 @@ private static string SeedBatch(AnyContext any) { any.DateTime().Generate().Ticks.ToString(CultureInfo.InvariantCulture) }; + // issue #215: broaden the compared batch beyond scalars — OrNull, array/sequence, pair/triple, + // StringMatching and enum draws. Collections and tuples inherit THIS seeded context through their + // operand (which carries the source), so every added draw is still reproducible and cross-asset stable. + parts.Add(any.Int32().Between(0, 100).OrNull().Generate() is int discount ? discount.ToString(CultureInfo.InvariantCulture) : "null"); + parts.Add(any.String().NonEmpty().WithMaxLength(8).OrNull().Generate() ?? "null"); + parts.Add(string.Join(",", Any.ArrayOf(any.Int32().Between(0, 9)).WithCount(3).Generate().Select(value => value.ToString(CultureInfo.InvariantCulture)))); + parts.Add(string.Join(",", Any.SequenceOf(any.Int32().Between(0, 9)).WithCount(2).Generate().Select(value => value.ToString(CultureInfo.InvariantCulture)))); + + (int, char) pair = Any.PairOf(any.Int32().Between(1, 9), any.Char()).Generate(); + parts.Add($"({pair.Item1.ToString(CultureInfo.InvariantCulture)},{pair.Item2})"); + + (bool, int, char) triple = Any.TripleOf(any.Boolean(), any.Int32().Between(0, 9), any.Char()).Generate(); + parts.Add($"({triple.Item1},{triple.Item2.ToString(CultureInfo.InvariantCulture)},{triple.Item3})"); + + parts.Add(any.StringMatching("[A-Z]{3}-[0-9]{4}").Generate()); + parts.Add(any.Enum().Generate().ToString()); + return string.Join("|", parts); } @@ -146,4 +202,8 @@ private static void Require(List failures, bool condition, string messag if (!condition) { failures.Add(message); } } + // A small closed enum for the enum-draw coverage (issue #215). Members render to stable, culture-independent + // names, keeping the enum part of the SEEDBATCH banner comparable across the two asset legs. + private enum Suit { Clubs, Diamonds, Hearts, Spades } + }