From bff465e1dc79c9b47a7c37136b616011892c52b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:43:38 +0000 Subject: [PATCH] test(dummies): guard Any/AnyContext mirror and builder algebra parity Add two pure-reflection tests over the library's hand-mirrored surfaces: - Mirror parity: every scalar factory on Any has an identical AnyContext counterpart. Composition and collection factories are excluded by construction (they take an IAny<> operand and inherit the context through their operand sources). - Algebra parity: each builder exposes exactly its family's constraint method set, encoded once as an expectation table. Both are pure reflection with no per-builder maintenance beyond that table, and both fail with a named diff when a factory or a constraint method drifts on one surface but not the other. Refs: #214 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011k7zRx6Xe2UkbBLNffx5m6 --- Dummies.UnitTests/SurfaceParityTests.cs | 171 ++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 Dummies.UnitTests/SurfaceParityTests.cs diff --git a/Dummies.UnitTests/SurfaceParityTests.cs b/Dummies.UnitTests/SurfaceParityTests.cs new file mode 100644 index 00000000..e90ba889 --- /dev/null +++ b/Dummies.UnitTests/SurfaceParityTests.cs @@ -0,0 +1,171 @@ +#region Usings declarations + +using System.Reflection; +using System.Threading.Tasks; + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +/// +/// Structural guards over the library's two hand-mirrored surfaces. Both are pure reflection, so they add no +/// per-builder maintenance beyond the expectation table encoded here: +/// +/// +/// Mirror parity. Every scalar factory on the static entry point has an +/// identical instance counterpart on . A scalar factory added to one surface and +/// forgotten on the other would compile and pass every behavioral test, silently shipping a hole in the +/// deterministic surface. +/// +/// +/// Algebra parity. Each builder exposes exactly the constraint method set its family declares. A +/// renamed or missing constraint on one of the cloned numeric or temporal builders would otherwise slip +/// past the copy-paste discipline that keeps the duplication safe. +/// +/// +/// Composition and collection factories (Combine, ListOf, DictionaryOf, ...) are deliberately +/// not mirrored onto : they inherit the context through their operand sources, so +/// the mirror guard excludes them by construction (they take an operand). +/// +public sealed class SurfaceParityTests { + + #region Mirror parity: Any <-> AnyContext + + [Fact(DisplayName = "Every Any scalar factory has an identical AnyContext counterpart.")] + public void AnyAndAnyContextExposeTheSameScalarFactories() { + HashSet onAny = typeof(Any) + .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(IsScalarFactory) + .Select(Signature) + .ToHashSet(); + + HashSet onContext = typeof(AnyContext) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(method => !method.IsSpecialName) // drops the Seed property getter + .Select(Signature) + .ToHashSet(); + + string[] onlyOnAny = onAny.Except(onContext).OrderBy(signature => signature, StringComparer.Ordinal).ToArray(); + string[] onlyOnContext = onContext.Except(onAny).OrderBy(signature => signature, StringComparer.Ordinal).ToArray(); + + Check.WithCustomMessage($"Scalar factories only on Any: [{string.Join(", ", onlyOnAny)}]; only on AnyContext: [{string.Join(", ", onlyOnContext)}].") + .That(onlyOnAny.Length + onlyOnContext.Length) + .IsEqualTo(0); + } + + // 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. + 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) + && !typeof(Task).IsAssignableFrom(returnType); + } + + private static bool IsAny(Type type) { + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IAny<>); + } + + // Name + generic arity + parameter types + return type, ignoring the static/instance distinction so the two + // surfaces line up. A drift in any of those four dimensions moves the signature and fails the guard. + private static string Signature(MethodInfo method) { + string parameters = string.Join(", ", method.GetParameters().Select(parameter => parameter.ParameterType.Name)); + + return $"{method.Name}`{method.GetGenericArguments().Length}({parameters}) -> {method.ReturnType.Name}"; + } + + #endregion + + #region Algebra parity: per-family constraint sets + + // The constraint vocabulary each family declares, encoded once as data. This table is the specification; the + // test compares it against what each builder actually exposes through reflection. + private static readonly string[] SignedNumericAlgebra = [ + "Positive", "Negative", "Zero", "NonZero", + "GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo", + "Between", "OneOf", "Except", "DifferentFrom" + ]; + + // Unsigned integers drop Positive/Negative (meaningless there — NonZero carries the intent). + private static readonly string[] UnsignedNumericAlgebra = [ + "Zero", "NonZero", + "GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo", + "Between", "OneOf", "Except", "DifferentFrom" + ]; + + // Instant-like builders rename the bound family to domain vocabulary, with identical inclusive/exclusive + // semantics, and carry no Positive/Negative/Zero (an instant has no sign). + private static readonly string[] InstantAlgebra = [ + "After", "AfterOrEqualTo", "Before", "BeforeOrEqualTo", + "Between", "OneOf", "Except", "DifferentFrom" + ]; + + public static IEnumerable Builders() { + // Signed integers, the continuous/decimal builders, and TimeSpan (a signed magnitude) share the full algebra. + yield return [typeof(AnyInt32), SignedNumericAlgebra]; + yield return [typeof(AnySByte), SignedNumericAlgebra]; + yield return [typeof(AnyInt16), SignedNumericAlgebra]; + yield return [typeof(AnyInt64), SignedNumericAlgebra]; + yield return [typeof(AnyDouble), SignedNumericAlgebra]; + yield return [typeof(AnySingle), SignedNumericAlgebra]; + yield return [typeof(AnyDecimal), SignedNumericAlgebra]; + yield return [typeof(AnyTimeSpan), SignedNumericAlgebra]; + + yield return [typeof(AnyByte), UnsignedNumericAlgebra]; + yield return [typeof(AnyUInt16), UnsignedNumericAlgebra]; + yield return [typeof(AnyUInt32), UnsignedNumericAlgebra]; + yield return [typeof(AnyUInt64), UnsignedNumericAlgebra]; + + yield return [typeof(AnyDateTime), InstantAlgebra]; + yield return [typeof(AnyDateTimeOffset), InstantAlgebra]; + + // The remaining scalar builders each carry their own deliberate set. + yield return [typeof(AnyBool), new[] { "True", "False", "DifferentFrom" }]; + yield return [typeof(AnyGuid), new[] { "NonEmpty", "Empty", "OneOf", "Except", "DifferentFrom" }]; + yield return [typeof(AnyEnum), new[] { "OneOf", "Except", "DifferentFrom" }]; + yield return [typeof(AnyChar), new[] { "Alpha", "AlphaNumeric", "Numeric", "UpperCase", "LowerCase", "OneOf", "Except", "DifferentFrom" }]; + + // AnyString is deliberately the only scalar builder with no exclusion constraints (no OneOf/Except/DifferentFrom). + yield return [typeof(AnyString), new[] { + "NonEmpty", "WithLength", "WithMinLength", "WithMaxLength", "WithLengthBetween", + "StartingWith", "EndingWith", "Containing", "Alpha", "AlphaNumeric", "Numeric", "UpperCase", "LowerCase" + }]; + +#if NET8_0_OR_GREATER + yield return [typeof(AnyInt128), SignedNumericAlgebra]; + yield return [typeof(AnyHalf), SignedNumericAlgebra]; + yield return [typeof(AnyUInt128), UnsignedNumericAlgebra]; + yield return [typeof(AnyDateOnly), InstantAlgebra]; + yield return [typeof(AnyTimeOnly), InstantAlgebra]; +#endif + } + + [Theory(DisplayName = "Each builder exposes exactly its family's constraint method set.")] + [MemberData(nameof(Builders))] + public void BuilderExposesExactlyItsFamilyAlgebra(Type builder, string[] expected) { + // A constraint method is fluent — it returns the builder itself. Generate() (returns the value) and the + // explicit interface members (not public) are excluded automatically. + HashSet actual = builder + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(method => method.ReturnType == builder && !method.IsSpecialName) + .Select(method => method.Name) + .ToHashSet(); + + string[] missing = expected.Except(actual).OrderBy(name => name, StringComparer.Ordinal).ToArray(); + string[] unexpected = actual.Except(expected).OrderBy(name => name, StringComparer.Ordinal).ToArray(); + + Check.WithCustomMessage($"{builder.Name} — missing: [{string.Join(", ", missing)}]; unexpected: [{string.Join(", ", unexpected)}].") + .That(missing.Length + unexpected.Length) + .IsEqualTo(0); + } + + #endregion + +}