Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -95,19 +95,22 @@ 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: |
set -euo pipefail
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"
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/dummies.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions Dummies.UnitTests/AnyCollectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,9 @@ public void FiniteScalarGeneratorsGateEagerly() {
Check.ThatCode(() => Any.SetOf(Any.Decimal().OneOf(1m, 2m)).WithCount(3)).Throws<ConflictingAnyConstraintException>();
Check.ThatCode(() => Any.SetOf(Any.Double().OneOf(1d, 2d)).WithCount(3)).Throws<ConflictingAnyConstraintException>();
Check.ThatCode(() => Any.SetOf(Any.Single().OneOf(1f, 2f)).WithCount(3)).Throws<ConflictingAnyConstraintException>();
#if NET8_0_OR_GREATER
Check.ThatCode(() => Any.SetOf(Any.Int128().Between(1, 3)).WithCount(5)).Throws<ConflictingAnyConstraintException>();
#endif

// Membership travels with cardinality: an out-of-domain contained value extends the effective domain...
for (int i = 0; i < SampleCount; i++) {
Expand Down
19 changes: 18 additions & 1 deletion Dummies.UnitTests/Dummies.UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<!-- Adds the .NET Framework 4.7.2 support-floor leg (gated by EnableNet472Floor) and owns the target
frameworks; exercised by the `framework-floor` job in .github/workflows/ci.yml. This carries Dummies'
OWN contract suite onto the floor, running it against the netstandard2.0 asset that .NET Framework
consumers load — until now that asset was only exercised transitively (ADR-0022; issue #215). -->
<Import Project="..\build\Net472TestFloor.props" />

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down Expand Up @@ -32,4 +37,16 @@
<Using Include="Xunit" />
</ItemGroup>

<!-- Three suites are saturated with .NET 8+ surface and cannot compile on the net472 floor: the modern
generators (DateOnly/TimeOnly/Int128/UInt128/Half), absent from the netstandard2.0 asset, and the
Math.BitIncrement / MathF / BitConverter.Half helpers (netstandard2.1+, absent from net472) used to build
their adjacent-value fixtures. They guard modern-.NET reachability and continuous-nudge behavior on
net10; on the floor the netstandard2.0 behavioral contract is exercised by the rest of the suite. The
other files' isolated net8-only draws are conditioned with `#if NET8_0_OR_GREATER` in-source. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
<Compile Remove="AnyModernTypeTests.cs" />
<Compile Remove="CrossEngineReachabilityTests.cs" />
<Compile Remove="ContinuousExclusionNudgeTests.cs" />
</ItemGroup>

</Project>
7 changes: 6 additions & 1 deletion Dummies.UnitTests/SeedReproducibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,21 @@ 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<int> list = Any.ListOf(Any.Int32().Between(0, 9)).WithCount(4).Generate();
HashSet<int> 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, 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);
}
Expand Down
4 changes: 3 additions & 1 deletion Dummies/README.nuget.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ matter — and that is the point.
`Boolean`, `Guid`, `Enum<T>` (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
Expand Down
64 changes: 62 additions & 2 deletions tools/dummies-check/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Reflection;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text.RegularExpressions;

using Dummies;

Expand Down Expand Up @@ -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<TargetFrameworkAttribute>()?.FrameworkName ?? "(none)";
Expand All @@ -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<string> failures = new();

// 1. The restored asset is the one this consumer TFM is meant to force.
Expand Down Expand Up @@ -111,10 +124,34 @@ private static void RunSmoke(List<string> failures) {
HashSet<int> 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<int> 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<Suit>().Generate();
Require(failures, System.Enum.IsDefined(typeof(Suit), suit), $"Enum<Suit>() 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));
Expand All @@ -123,6 +160,8 @@ private static void RunSmoke(List<string> 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<string> parts = new() {
any.Int32().Generate().ToString(CultureInfo.InvariantCulture),
Expand All @@ -139,11 +178,32 @@ 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<Suit>().Generate().ToString());

return string.Join("|", parts);
}

private static void Require(List<string> failures, bool condition, string message) {
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 }

}