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
172 changes: 172 additions & 0 deletions JustDummies.UnitTests/ValueIdentityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#region Usings declarations

using JetBrains.Annotations;

using NFluent;

#endregion

namespace JustDummies.UnitTests;

/// <summary>
/// The value identity of the small values the failure-reporting path is built from. Each is immutable and
/// documented as a value, so each answers "is this the same one?" by what it holds rather than by which instance
/// it is — the answer a reference type gives by default, and gives silently.
/// </summary>
/// <remarks>
/// Example-suite material (ADR-0040): each case pins one named pair, and there is no argument to quantify over.
/// <see cref="ConstraintCall" /> has its own equality cases in <see cref="ConstraintCallTests" />; this fixture
/// covers the two values built beside it.
/// </remarks>
[TestSubject(typeof(ConstraintClaim))]
public sealed class ValueIdentityTests {

#region Statics members declarations

private static ConstraintCall Length(string bound) {
return ConstraintCall.Of("WithLength", bound);
}

#endregion

// The halves are asserted directly rather than only through ToString and equality: a getter carries no logic, so
// a mutation score says nothing about it, and Constraint is what the blame choice reads.
[Fact(DisplayName = "A claim on a constraint exposes the constraint, its rendering and its clause.")]
public void ClaimOnAConstraintExposesItsHalves() {
ConstraintCall length = Length("3");
ConstraintClaim claim = ConstraintClaim.Of(length, "already fixes the length at 3");

Check.That(claim.Constraint).IsEqualTo(length);
Check.That(claim.Subject).IsEqualTo("WithLength(3)");
Check.That(claim.Claims).IsEqualTo("already fixes the length at 3");
Check.That(claim.ToString()).IsEqualTo("WithLength(3) already fixes the length at 3");
}

[Fact(DisplayName = "A claim on a phrase exposes the phrase and carries no constraint.")]
public void ClaimOnAPhraseCarriesNoConstraint() {
ConstraintClaim claim = ConstraintClaim.OfPhrase("the contained value \"ABC\"", "contains 'x', which it does not allow");

Check.That(claim.Constraint).IsNull();
Check.That(claim.Subject).IsEqualTo("the contained value \"ABC\"");
Check.That(claim.Claims).IsEqualTo("contains 'x', which it does not allow");
Check.That(claim.ToString()).IsEqualTo("the contained value \"ABC\" contains 'x', which it does not allow");
}

[Fact(DisplayName = "A replay exposes the seed it was given and the guidance naming it.")]
public void ReplayExposesItsSeedAndGuidance() {
FixedRandomSource source = new(7);

Replay full = Replay.Of(source, 42);
Replay partial = Replay.PartialOf(source);

Check.That(full.Seed).IsEqualTo(42);
Check.That(full.Guidance).Contains("42");
// The seed is supplied, not read back from the source, so the two need not agree.
Check.That(partial.Seed).IsEqualTo(7);
Check.That(partial.Guidance).Contains("not reproducible from this seed alone");
}

// Rendered for a reader rather than for a message: nothing appends a replay whole — AnyGenerationException takes
// its guidance and its seed separately — so this exists for the debugger, which the type points at it.
[Fact(DisplayName = "A replay renders its seed and guidance rather than its type name.")]
public void ReplayRendersItself() {
FixedRandomSource source = new(7);

string rendered = Replay.Of(source, 42).ToString();

Check.That(rendered).StartsWith("seed 42:");
Check.That(rendered).Contains("Any.WithSeed(42)");
Check.That(rendered).Not.Contains(nameof(Replay));
}

[Fact(DisplayName = "Two claims blaming the same constraint for the same thing are equal.")]
public void ClaimsWithTheSameConstraintAndClaimAreEqual() {
ConstraintClaim first = ConstraintClaim.Of(Length("3"), "already fixes the length at 3");
ConstraintClaim second = ConstraintClaim.Of(Length("3"), "already fixes the length at 3");

Check.That(first.Equals(second)).IsTrue();
Check.That(first == second).IsTrue();
Check.That(first != second).IsFalse();
Check.That(first.GetHashCode()).IsEqualTo(second.GetHashCode());
}

[Fact(DisplayName = "A claim differs when its constraint differs, and when its claim does.")]
public void ClaimsDifferOnEitherHalf() {
ConstraintClaim reference = ConstraintClaim.Of(Length("3"), "already fixes the length at 3");

Check.That(reference == ConstraintClaim.Of(Length("5"), "already fixes the length at 3")).IsFalse();
Check.That(reference == ConstraintClaim.Of(Length("3"), "already caps the length at 3")).IsFalse();
}

// The blame choice turns on whether a claim's subject IS the constraint being applied, so a phrase that merely
// reads like one must not pass for it — which is what keeps the two apart here.
[Fact(DisplayName = "A phrase never equals a claim on the constraint it reads like.")]
public void APhraseIsNotTheConstraintItReadsLike() {
ConstraintClaim onAConstraint = ConstraintClaim.Of(Length("3"), "already fixes the length at 3");
ConstraintClaim onAPhrase = ConstraintClaim.OfPhrase("WithLength(3)", "already fixes the length at 3");

Check.That(onAConstraint.ToString()).IsEqualTo(onAPhrase.ToString());
Check.That(onAConstraint == onAPhrase).IsFalse();
}

[Fact(DisplayName = "A claim equals neither null nor a value of another type.")]
public void ClaimEqualsNeitherNullNorAnotherType() {
ConstraintClaim claim = ConstraintClaim.Of(Length("3"), "already fixes the length at 3");
ConstraintClaim? nothing = null;
object text = "WithLength(3) already fixes the length at 3";

Check.That(claim.Equals(nothing)).IsFalse();
Check.That(claim.Equals(text)).IsFalse();
Check.That(claim == nothing).IsFalse();
Check.That(claim != nothing).IsTrue();
Check.That(nothing == null).IsTrue();
}

[Fact(DisplayName = "Two replays of the same run under the same seed are equal.")]
public void ReplaysOfTheSameRunAreEqual() {
FixedRandomSource source = new(7);

Replay first = Replay.Of(source, 42);
Replay second = Replay.Of(source, 42);

Check.That(first.Equals(second)).IsTrue();
Check.That(first == second).IsTrue();
Check.That(first.GetHashCode()).IsEqualTo(second.GetHashCode());
}

[Fact(DisplayName = "A replay differs when its seed differs.")]
public void ReplaysDifferOnTheirSeed() {
FixedRandomSource source = new(7);

Check.That(Replay.Of(source, 42) == Replay.Of(source, 43)).IsFalse();
Check.That(Replay.Of(source, 42) != Replay.Of(source, 43)).IsTrue();
}

// The seed alone does not settle it: the same seed replays a run in full or only in part depending on whether a
// foreign generator contributed values this source never drew.
[Fact(DisplayName = "A partial replay differs from a full one carrying the same seed.")]
public void APartialReplayIsNotAFullOne() {
FixedRandomSource source = new(7);

Replay full = Replay.Of(source);
Replay partial = Replay.PartialOf(source);

Check.That(full.Seed).IsEqualTo(partial.Seed);
Check.That(full == partial).IsFalse();
}

[Fact(DisplayName = "A replay equals neither null nor a value of another type.")]
public void ReplayEqualsNeitherNullNorAnotherType() {
FixedRandomSource source = new(7);
Replay replay = Replay.Of(source, 42);
Replay? nothing = null;
object text = "42";

Check.That(replay.Equals(nothing)).IsFalse();
Check.That(replay.Equals(text)).IsFalse();
Check.That(replay == nothing).IsFalse();
Check.That(replay != nothing).IsTrue();
Check.That(nothing == null).IsTrue();
}

}
125 changes: 125 additions & 0 deletions JustDummies.UnitTests/ValueObjectConventionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#region Usings declarations

using System.Diagnostics;
using System.Reflection;

using NFluent;

#endregion

namespace JustDummies.UnitTests;

/// <summary>
/// The value-object convention, enforced by reflection over the whole library: a type marked
/// <c>[ValueObject]</c> is sealed, immutable, renders itself, and carries the full identity set —
/// <see cref="IEquatable{T}" />, both <c>Equals</c> overloads, <c>GetHashCode</c>, <c>ToString</c> behind a
/// <see cref="DebuggerDisplayAttribute" />, and <c>==</c>/<c>!=</c>.
/// </summary>
/// <remarks>
/// <para>
/// This exists because the gap it closes is silent. A reference type compares by identity when nobody writes
/// another answer, and nothing complains: not the compiler, not a test, not a reviewer reading a type that
/// calls itself a value in its own remarks. Two of this library's values shipped that way, and only the third
/// had its equality — because code happened to compare it with <c>==</c>, which forced the question. Nothing
/// forced it for the others.
/// </para>
/// <para>
/// The marker is what makes the rule enforceable without guessing: immutability alone would sweep in the
/// generators and the specifications, which are immutable recipes rather than values. Declaring a value is
/// therefore the decision, and this test is what holds it to it.
/// </para>
/// <para>
/// Structure is all reflection can settle, and it is the part that goes missing: whether two equal instances
/// really hash alike belongs to each type's own tests. What is checked here cannot be satisfied by accident.
/// </para>
/// </remarks>
public sealed class ValueObjectConventionTests {

private static readonly Assembly LibraryAssembly = typeof(Any).Assembly;

[Fact(DisplayName = "Every type declared a value object carries a full value identity.")]
public void EveryDeclaredValueObjectCarriesAValueIdentity() {
List<Type> values = LibraryAssembly.GetTypes()
.Where(type => type.GetCustomAttribute<ValueObjectAttribute>() is not null)
.OrderBy(type => type.Name, StringComparer.Ordinal)
.ToList();

// Guards the scan itself: a renamed attribute or a moved assembly would leave the enumeration empty and every
// assertion below would pass vacuously. Emptiness is the failure mode; the exact count is not pinned, so
// retiring a value never trips this instead of saying what really changed.
Check.WithCustomMessage("No type is marked [ValueObject]; the scan lost its target.")
.That(values).Not.IsEmpty();

List<string> violations = [];
foreach (Type value in values) {
violations.AddRange(MissingFrom(value).Select(missing => $"{value.Name}: {missing}"));
}

Check.WithCustomMessage(
$"Value-object convention — {violations.Count} missing member(s) or property(ies):{Environment.NewLine}"
+ string.Join(Environment.NewLine, violations))
.That(violations)
.IsEmpty();
}

#region Per-type verification

private static IEnumerable<string> MissingFrom(Type value) {
// A struct yields a zero-initialized instance through its parameterless constructor, bypassing every
// validating factory — which is why a value enforcing an invariant is a class in this repository.
if (value.IsValueType) { yield return "is a struct; a value enforcing an invariant is a class here"; }

// An unsealed value cannot keep equality symmetric: a subclass compares unequal to its base under one
// direction of the comparison and equal under the other.
if (!value.IsValueType && !value.IsSealed) { yield return "is not sealed"; }

if (!typeof(IEquatable<>).MakeGenericType(value).IsAssignableFrom(value)) {
yield return $"does not implement IEquatable<{value.Name}>";
}

if (!DeclaresMethod(value, nameof(Equals), typeof(object))) { yield return "does not override Equals(object)"; }
if (!DeclaresMethod(value, nameof(GetHashCode))) { yield return "does not override GetHashCode()"; }

// A value that does not render itself shows a debugger its type name, which is the one thing the reader
// already knows. The attribute is what puts that rendering in front of them without expanding the instance.
if (!DeclaresMethod(value, nameof(ToString))) { yield return "does not override ToString()"; }

DebuggerDisplayAttribute? display = value.GetCustomAttribute<DebuggerDisplayAttribute>();
if (display is null) {
yield return "does not carry [DebuggerDisplay]";
} else if (display.Value?.Contains(nameof(ToString)) != true) {
yield return $"carries [DebuggerDisplay(\"{display.Value}\")] rather than forwarding to ToString()";
}

// The operator pair is the silent half of the contract: without it `a == b` compiles and compares references,
// where a missing Equals would at least be visible to anyone reading the type.
if (!DeclaresOperator(value, "op_Equality")) { yield return "does not define operator =="; }
if (!DeclaresOperator(value, "op_Inequality")) { yield return "does not define operator !="; }

foreach (string mutable in MutableStateOf(value)) { yield return mutable; }
}

private static IEnumerable<string> MutableStateOf(Type value) {
foreach (FieldInfo field in value.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) {
if (!field.IsInitOnly) { yield return $"field '{field.Name}' is not readonly"; }
}

foreach (PropertyInfo property in value.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) {
if (property.SetMethod is not null) { yield return $"property '{property.Name}' has a setter"; }
}
}

private static bool DeclaresMethod(Type value, string name, params Type[] parameters) {
MethodInfo? declared = value.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, parameters, null);

return declared is not null && declared.DeclaringType == value;
}

private static bool DeclaresOperator(Type value, string name) {
return value.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Any(method => method.Name == name && method.GetParameters().Length == 2);
}

#endregion

}
8 changes: 8 additions & 0 deletions JustDummies/ConstraintCall.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
#region Usings declarations

using System.Diagnostics;

#endregion

namespace JustDummies;

/// <summary>
Expand Down Expand Up @@ -40,6 +46,8 @@ namespace JustDummies;
/// restate it, and could not be reached from C# without defeating the annotation it duplicates.
/// </para>
/// </remarks>
[DebuggerDisplay("{ToString()}")]
[ValueObject]
internal sealed class ConstraintCall : IEquatable<ConstraintCall> {

#region Statics members declarations
Expand Down
Loading
Loading