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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@ jobs:
# because its fixtures bind DateOnly, a .NET 6+ type absent from net472. RequestBinder is still floored here
# 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).
# Dummies.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.
# 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.
# and fail) keeps the net472 scope exactly these five.
- name: Test the netstandard2.0 libraries on .NET Framework 4.7.2
shell: bash
run: |
Expand All @@ -110,7 +112,8 @@ jobs:
FirstClassErrors.UnitTests \
FirstClassErrors.PropertyTests \
FirstClassErrors.RequestBinder.PropertyTests \
Dummies.UnitTests ; do
Dummies.UnitTests \
Dummies.Xunit.UnitTests ; do
echo "::group::$proj (net472)"
dotnet test "$proj/$proj.csproj" -c Release -f net472 -p:EnableNet472Floor=true \
--logger "console;verbosity=normal"
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<PackageVersion Include="Verify.XunitV3" Version="31.26.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
</ItemGroup>

</Project>
209 changes: 209 additions & 0 deletions Dummies.UnitTests/AmbientSeedScopeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
#region Usings declarations

using JetBrains.Annotations;

using NFluent;

#endregion

namespace Dummies.UnitTests;

/// <summary>
/// The scope form of reproducibility: a handle a caller opens and disposes itself, for a test-framework adapter
/// that observes a test through before/after hooks and therefore has no delegate to wrap (ADR-0035). The seed
/// behaviour must match <c>Any.Reproducibly</c>; what the scope adds is the replay instruction a
/// generation-failure diagnostic names, so a run pinned from outside the test body never advertises a call the
/// test does not contain.
/// </summary>
[TestSubject(typeof(Any))]
public sealed class AmbientSeedScopeTests {

#region Statics members declarations

private static (int, string) Batch() {
return (Any.Int32().Generate(), Any.String().NonEmpty().Generate());
}

#endregion

[Fact(DisplayName = "UseSeed pins the ambient context, so the same seed yields the same values.")]
public void UseSeedPinsTheAmbientContext() {
(int, string) first;
(int, string) second;

using (Any.UseSeed(1234)) { first = Batch(); }
using (Any.UseSeed(1234)) { second = Batch(); }

Check.That(second).IsEqualTo(first);
}

[Fact(DisplayName = "UseSeed with different seeds produces different sequences.")]
public void DifferentSeedsDiffer() {
(int, string) fromOne;
(int, string) fromTwo;

using (Any.UseSeed(1)) { fromOne = Batch(); }
using (Any.UseSeed(2)) { fromTwo = Batch(); }

Check.That(fromTwo).IsNotEqualTo(fromOne);
}

[Fact(DisplayName = "UseSeed pins the same sequence as Reproducibly for the same seed.")]
public void UseSeedMatchesReproducibly() {
(int, string) fromScope;
(int, string) fromRunner = default;

using (Any.UseSeed(4242)) { fromScope = Batch(); }
Any.Reproducibly(4242, () => { fromRunner = Batch(); });

Check.That(fromScope).IsEqualTo(fromRunner);
}

[Fact(DisplayName = "Disposing the scope restores the previous context with its draw sequence intact.")]
public void DisposingRestoresThePreviousContext() {
(int, string) first;
(int, string) second;
(int, string) restoredFirst;
(int, string) restoredSecond;

using (Any.UseSeed(7)) {
first = Batch();
second = Batch();
}

using (Any.UseSeed(7)) {
restoredFirst = Batch();
// A nested scope draws from its own generator; the outer one must neither be consumed nor reset by
// it, so the outer sequence resumes exactly where it was interrupted.
using (Any.UseSeed(99)) { Batch(); }
restoredSecond = Batch();
}

Check.That(restoredFirst).IsEqualTo(first);
Check.That(restoredSecond).IsEqualTo(second);
}

[Fact(DisplayName = "Nested scopes pin the inner seed while they are open.")]
public void NestedScopesPinTheInnerSeed() {
(int, string) standalone;
(int, string) nested;

using (Any.UseSeed(555)) { standalone = Batch(); }

using (Any.UseSeed(111)) {
using (Any.UseSeed(555)) { nested = Batch(); }
}

Check.That(nested).IsEqualTo(standalone);
}

[Fact(DisplayName = "Disposing the scope twice is harmless.")]
public void DisposingTwiceIsHarmless() {
IDisposable scope = Any.UseSeed(31);

scope.Dispose();

Check.ThatCode(() => scope.Dispose()).DoesNotThrow();
}

[Fact(DisplayName = "The scope does not leak across parallel execution contexts.")]
public async Task TheScopeDoesNotLeakAcrossExecutionContexts() {
(int, string) inside;
(int, string) outside = default;

using (Any.UseSeed(2026)) {
inside = Batch();

// A task started inside the scope inherits it, so run the probe on a context that never saw it.
await Task.Run(() => { outside = Batch(); }, TestContext.Current.CancellationToken);
}

// The probe drew from a context whose scope was never entered, so it cannot have replayed the pinned
// sequence. (An unseeded draw could coincide, but not across both components of the batch.)
Check.That(outside).IsNotEqualTo(inside);
}

[Fact(DisplayName = "Without a replay instruction, a generation failure names Any.Reproducibly.")]
public void WithoutAnInstructionTheFailureNamesTheDelegateRunner() {
AnyGenerationException caught;

using (Any.UseSeed(1234)) {
caught = Assert.Throws<AnyGenerationException>(
() => Any.Int32().As<int, int>(_ => throw new InvalidOperationException("rejected")).Generate());
}

Check.That(caught.Seed).IsEqualTo(1234);
Check.That(caught.Message).Contains("The arbitrary values were seeded with 1234");
Check.That(caught.Message).Contains("Any.Reproducibly(1234, ...)");
}

[Fact(DisplayName = "With a replay instruction, a generation failure names it instead of Any.Reproducibly.")]
public void WithAnInstructionTheFailureNamesIt() {
AnyGenerationException caught;

using (Any.UseSeed(1234, "[Reproducible(Seed = 1234)]")) {
caught = Assert.Throws<AnyGenerationException>(
() => Any.Int32().As<int, int>(_ => throw new InvalidOperationException("rejected")).Generate());
}

Check.That(caught.Message).Contains("The arbitrary values were seeded with 1234");
Check.That(caught.Message).Contains("[Reproducible(Seed = 1234)]");
// The whole point: the reader is never pointed at a call their test does not contain.
Check.That(caught.Message).Not.Contains("Any.Reproducibly");
}

[Fact(DisplayName = "The replay instruction also reaches the partial-replay guidance.")]
public void TheInstructionReachesThePartialReplayGuidance() {
AnyGenerationException caught;

using (Any.UseSeed(777, "[Reproducible(Seed = 777)]")) {
IAny<int> foreign = new ForeignAny();
caught = Assert.Throws<AnyGenerationException>(
() => Any.Combine<int, int, int>(Any.Int32(), foreign, (_, _) => throw new InvalidOperationException("rejected")).Generate());
}

Check.That(caught.Message).Contains("not reproducible from this seed alone");
Check.That(caught.Message).Contains("[Reproducible(Seed = 777)]");
Check.That(caught.Message).Not.Contains("Any.Reproducibly");
}

[Fact(DisplayName = "The replay instruction is scoped: it does not outlive the scope that supplied it.")]
public void TheInstructionDoesNotOutliveItsScope() {
using (Any.UseSeed(1, "[Reproducible(Seed = 1)]")) { }

AnyGenerationException caught;
using (Any.UseSeed(2)) {
caught = Assert.Throws<AnyGenerationException>(
() => Any.Int32().As<int, int>(_ => throw new InvalidOperationException("rejected")).Generate());
}

Check.That(caught.Message).Contains("Any.Reproducibly(2, ...)");
Check.That(caught.Message).Not.Contains("[Reproducible");
}

[Fact(DisplayName = "UseSeed rejects a null replay instruction.")]
public void UseSeedRejectsANullInstruction() {
Check.ThatCode(() => Any.UseSeed(1, null!)).Throws<ArgumentNullException>();
}

[Theory(DisplayName = "UseSeed rejects a blank replay instruction.")]
[InlineData("")]
[InlineData(" ")]
public void UseSeedRejectsABlankInstruction(string instruction) {
Check.ThatCode(() => Any.UseSeed(1, instruction)).Throws<ArgumentException>();
}

#region Nested types

/// <summary>A generator carrying no random source, so a derivation over it cannot promise a full replay.</summary>
private sealed class ForeignAny : IAny<int> {

public int Generate() {
return 42;
}

}

#endregion

}
7 changes: 5 additions & 2 deletions Dummies.UnitTests/SurfaceParityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,18 @@ public void AnyAndAnyContextExposeTheSameScalarFactories() {

// A scalar factory produces a generator from the context's own source: it returns a builder and takes no
// IAny<> operand. That excludes the composition/collection factories that live only on Any (Combine, ListOf,
// SetOf, DictionaryOf, PairOf, ...), as well as WithSeed (returns AnyContext) and Reproducibly (returns
// void/Task) — none of which AnyContext is meant to mirror.
// SetOf, DictionaryOf, PairOf, ...), as well as the three ways to control seeding — WithSeed (returns
// AnyContext), Reproducibly (returns void/Task) and UseSeed (returns IDisposable). None of those is a
// generator factory, and AnyContext is not meant to mirror them: it already *is* an explicit deterministic
// context, so pinning a seed on one would be meaningless.
private static bool IsScalarFactory(MethodInfo method) {
if (method.GetParameters().Any(parameter => IsAny(parameter.ParameterType))) { return false; }

Type returnType = method.ReturnType;

return returnType != typeof(AnyContext)
&& returnType != typeof(void)
&& returnType != typeof(IDisposable)
&& !typeof(Task).IsAssignableFrom(returnType);
}

Expand Down
44 changes: 44 additions & 0 deletions Dummies.Xunit.UnitTests/ArchitectureTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#region Usings declarations

using System.Reflection;

using NFluent;

#endregion

namespace Dummies.Xunit.UnitTests;

/// <summary>
/// Guards the boundary of the companion package. Dummies itself may depend on nothing beyond the standard
/// library (ADR-0011), which is precisely why the xUnit adapter is a separate package (ADR-0036): it exists to
/// carry the one dependency Dummies cannot. What it must never carry is a FirstClassErrors dependency — the
/// error-agnostic promise applies to the whole Dummies line, not just its core assembly.
/// </summary>
public sealed class ArchitectureTests {

[Fact(DisplayName = "Dummies.Xunit references no FirstClassErrors assembly.")]
public void DummiesXunitReferencesNoFirstClassErrorsAssembly() {
AssemblyName[] references = typeof(ReproducibleAttribute).Assembly.GetReferencedAssemblies();

foreach (AssemblyName reference in references) {
Check.WithCustomMessage($"Unexpected assembly reference: {reference.Name}")
.That(reference.Name!.StartsWith("FirstClassErrors", StringComparison.Ordinal)).IsFalse();
}
}

[Fact(DisplayName = "Dummies.Xunit depends on nothing beyond the standard library, Dummies and xUnit.")]
public void DummiesXunitDependsOnlyOnDummiesAndXunit() {
AssemblyName[] references = typeof(ReproducibleAttribute).Assembly.GetReferencedAssemblies();

foreach (AssemblyName reference in references) {
// The exact facade split varies with the SDK, so the guard checks the intent — the standard
// library, the library being adapted, and the framework it is adapted to — not a fixed list.
bool expected = reference.Name is "netstandard" or "mscorlib" or "Dummies"
|| reference.Name!.StartsWith("System.", StringComparison.Ordinal)
|| reference.Name.StartsWith("xunit.", StringComparison.Ordinal);

Check.WithCustomMessage($"Unexpected assembly reference: {reference.Name}").That(expected).IsTrue();
}
}

}
40 changes: 40 additions & 0 deletions Dummies.Xunit.UnitTests/Dummies.Xunit.UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<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. Dummies.Xunit is a
netstandard2.0 package, so a .NET Framework consumer loads that asset — this carries the adapter's
contract suite onto the floor it actually claims to support (ADR-0022). -->
<Import Project="..\build\Net472TestFloor.props" />

<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NFluent" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" />
</ItemGroup>

<ItemGroup>
<!-- The adapter under test, and Dummies for the values it pins. Deliberately no FirstClassErrors
reference: the standalone boundary of the package under test stays visible in its own test bed. -->
<ProjectReference Include="..\Dummies.Xunit\Dummies.Xunit.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
Loading