diff --git a/CsCheck/Check.cs b/CsCheck/Check.cs index 5d2c60f..d39919d 100644 --- a/CsCheck/Check.cs +++ b/CsCheck/Check.cs @@ -3579,6 +3579,99 @@ public static Task FasterAsync(this Gen<(T1, => FasterAsync(gen, t => faster(t.Item1, t.Item2, t.Item3, t.Item4, t.Item5, t.Item6, t.Item7, t.Item8), t => slower(t.Item1, t.Item2, t.Item3, t.Item4, t.Item5, t.Item6, t.Item7, t.Item8), equal, sigma, threads, repeat, timeout, seed, raiseexception, writeLine); + internal sealed class FasterResult(double sigma, int repeat) + { + readonly double Limit = sigma * sigma; + public Exception? Exception; + public int Faster, Slower; + public long FasterMin = long.MaxValue, SlowerMin = long.MaxValue; + public MedianEstimator Median = new(); + bool completed; + + private float SigmaSquared + { + // Binomial distribution: Mean = n p, Variance = n p q in this case H0 has n = Faster + Slower, p = 0.5, and q = 0.5 + // sigmas = Abs(Faster - Mean) / Sqrt(Variance) = Sqrt((Faster - Slower)^2/(Faster + Slower)) + get + { + float d = Faster - Slower; + return d * d / (Faster + Slower); + } + } + + public bool NotFaster => Slower > Faster || Median.Median < 0.0; + + public bool Add(long faster, long slower) + { + lock (Median) + { + if (completed) return false; + if (faster < FasterMin) FasterMin = faster; + if (slower < SlowerMin) SlowerMin = slower; + double ratio; + if (slower > faster) + { + ratio = (double)(slower - faster) / slower; + Faster++; + } + else if (slower != faster) + { + ratio = (double)(slower - faster) / faster; + Slower++; + } + else + { + ratio = 0d; + } + Median.Add(ratio); + if (SigmaSquared < Limit) return false; + completed = true; + return true; + } + } + + public override string ToString() + { + var times = Median.Median >= 0.0 ? 1 / (1 - Median.Median) : 1 + Median.Median; + var q1Times = Median.Q1 >= 0.0 ? 1 / (1 - Median.Q1) : 1 + Median.Q1; + var q3Times = Median.Q3 >= 0.0 ? 1 / (1 - Median.Q3) : 1 + Median.Q3; + var faster = Median.Median >= 0.0 ? "faster" : "slower"; + if (Median.Median < 0.0) + { + times = 1 / times; + (q1Times, q3Times) = (1 / q3Times, 1 / q1Times); + } + var (timeString, timeUnit) = TimeFormat((double)Math.Min(FasterMin, SlowerMin) / repeat); + var result = $"{Median.Median:P2}[{Median.Q1:P2}..{Median.Q3:P2}] {times:#0.00}x[{q1Times:#0.00}x..{q3Times:#0.00}x] {faster}"; + if (double.IsNaN(Median.Median)) result = $"Time resolution too small try using repeat.\n{result}"; + else if ((Median.Median >= 0.0) != (Faster > Slower)) result = $"Inconsistent result try using repeat or increasing sigma.\n{result}"; + result = $"{result}, sigma = {Math.Sqrt(SigmaSquared):#0.0} ({Faster:#,0} vs {Slower:#,0}), min = {timeString((double)FasterMin / repeat)}{timeUnit} vs {timeString((double)SlowerMin / repeat)}{timeUnit}"; + if (Check.IsDebug) result += " - DEBUG MODE - DO NOT TRUST THESE RESULTS"; + return result; + } + + private static (Func, string) TimeFormat(double maxValue) => + (maxValue * 1000 / Stopwatch.Frequency) switch + { + >= 1000000 => (d => (d / Stopwatch.Frequency).ToString("###0"), "s"), + >= 100000 => (d => (d / Stopwatch.Frequency).ToString("###0.#"), "s"), + >= 10000 => (d => (d / Stopwatch.Frequency).ToString("###0.##"), "s"), + >= 1000 => (d => (d * 1000 / Stopwatch.Frequency).ToString("###0"), "ms"), + >= 100 => (d => (d * 1000 / Stopwatch.Frequency).ToString("###0.#"), "ms"), + >= 10 => (d => (d * 1000 / Stopwatch.Frequency).ToString("###0.##"), "ms"), + >= 1 => (d => (d * 1000 / Stopwatch.Frequency).ToString("###0.###"), "ms"), + >= 0.1 => (d => (d * 1_000_000 / Stopwatch.Frequency).ToString("###0.#"), "μs"), + >= 0.01 => (d => (d * 1_000_000 / Stopwatch.Frequency).ToString("###0.##"), "μs"), + >= 0.001 => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0"), "ns"), + >= 0.0001 => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0.#"), "ns"), + >= 0.00001 => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0.##"), "ns"), + >= 0.000001 => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0.###"), "ns"), + _ => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0.####"), "ns"), + }; + + public void Output(Action output) => output(ToString()); + } + /// Generate a single random example. /// The data generator. public static T Single(this Gen gen) @@ -3623,37 +3716,6 @@ public static T Single(this Gen gen, Func predicate, string seed) throw new CsCheckException("predicate no longer satisfied"); } - /// Check Equals, and GetHashCode are consistent. - /// The sample input data generator. - /// The initial seed to use for the first iteration. - /// The number of iterations to run in the sample (default 100). - /// The number of seconds to run the sample. - /// The number of threads to run the sample on (default number logical CPUs). - /// A function to convert the input data to a string for error reporting (default Check.Print). - public static void Equality(this Gen gen, string? seed = null, long iter = -1, int time = -1, int threads = -1, Func<(T, T), string>? print = null) - { - if (iter == -1) iter = Iter; - if (iter > 1) iter /= 2; - if (time == -1) time = Time; - if (time > 1) time /= 2; - - gen.Clone().Sample((t1, t2) => - t1!.Equals(t2) && t2!.Equals(t1) && Equals(t1, t2) && t1.GetHashCode() == t2.GetHashCode() - && (t1 is not IEquatable e || (e.Equals(t2) && ((IEquatable)t2).Equals(t1))) - , null, seed, iter, time, threads, print); - - gen.Select(gen).Sample((t1, t2) => - { - bool equal = t1!.Equals(t2); - return - (!equal && !t2!.Equals(t1) && !Equals(t1, t2) - && (t1 is not IEquatable e2 || (!e2.Equals(t2) && !((IEquatable)t2).Equals(t1)))) - || - (equal && t2!.Equals(t1) && Equals(t1, t2) && t1.GetHashCode() == t2.GetHashCode() - && (t1 is not IEquatable e || (e.Equals(t2) && ((IEquatable)t2).Equals(t1)))); - }, null, seed, iter, time, threads, print); - } - /// Check a hash of a series of values. Cache values on a correct run and fail with stack trace at first difference. /// The code called to add values into the hash. /// The expected hash value set after an initial run to find it. @@ -3722,97 +3784,259 @@ public static BigO BigO(int[] n, Func> genN, Action action, lo y[i].Add(timer.Time(gens[i].Generate(pcg, null, out _))); return BigO(Array.ConvertAll(n, i => (double)i), Array.ConvertAll(y, m => m.Median), constantFactor); } -} - -internal sealed class FasterResult(double sigma, int repeat) -{ - readonly double Limit = sigma * sigma; - public Exception? Exception; - public int Faster, Slower; - public long FasterMin = long.MaxValue, SlowerMin = long.MaxValue; - public MedianEstimator Median = new(); - bool completed; - private float SigmaSquared + /// Check Equals, and GetHashCode are consistent. + /// + /// When is supplied the field contract is: a compared field is one where changing it to a + /// meaningfully-different value always breaks equality, independent of the other fields; an ignored field is one + /// where changing it never affects equality. Fields whose effect on equality is conditional or derived from a + /// combination of fields (e.g. equality on Math.Max(A, B)) do not fit this binary and should not be declared. + /// For a field whose equality is normalized (rounding, tolerance, case-insensitive, etc.) ensure the two values are + /// meaningfully different by either the Gen (generate values that stay distinct once set) or a matching comparer, + /// or both. A field that affects equality but is not declared is detected as a failure. + /// + /// The sample input data generator. + /// Optionally define compared and ignored fields to check equality includes/excludes them. Changing a compared field must make two equal instances unequal; changing an ignored field must keep them equal. A field that affects equality but is not declared is detected as a failure. + /// The initial seed to use for the first iteration. + /// The number of iterations to run in the sample (default 100). + /// The number of seconds to run the sample. + /// The number of threads to run the sample on (default number logical CPUs). + /// A function to convert the input data to a string for error reporting (default Check.Print). + public static void Equality(this Gen gen, Func, EqualityFields>? fields = null, string? seed = null, long iter = -1, int time = -1, int threads = -1, + Func<(T, T), string>? print = null) { - // Binomial distribution: Mean = n p, Variance = n p q in this case H0 has n = Faster + Slower, p = 0.5, and q = 0.5 - // sigmas = Abs(Faster - Mean) / Sqrt(Variance) = Sqrt((Faster - Slower)^2/(Faster + Slower)) - get + if (iter == -1) iter = Iter; + if (iter > 1) iter /= 2; + if (time == -1) time = Time; + if (time > 1) time /= 2; + + gen.Clone().Sample((t1, t2) => + t1!.Equals(t2) && t2!.Equals(t1) && Equals(t1, t2) && t1.GetHashCode() == t2.GetHashCode() + && (t1 is not IEquatable e || (e.Equals(t2) && ((IEquatable)t2).Equals(t1))) + , null, seed, iter, time, threads, print); + + gen.Select(gen).Sample((t1, t2) => + { + bool equal = t1!.Equals(t2); + return + (!equal && !t2!.Equals(t1) && !Equals(t1, t2) + && (t1 is not IEquatable e2 || (!e2.Equals(t2) && !((IEquatable)t2).Equals(t1)))) + || + (equal && t2!.Equals(t1) && Equals(t1, t2) && t1.GetHashCode() == t2.GetHashCode() + && (t1 is not IEquatable e || (e.Equals(t2) && ((IEquatable)t2).Equals(t1)))); + }, null, seed, iter, time, threads, print); + + if (fields is not null) { - float d = Faster - Slower; - return d * d / (Faster + Slower); + var f = fields(new EqualityFields()); + if (f.ComparedFields.Count > 0 || f.IgnoredFields.Count > 0) + SampleEqualityFields(gen, static (a, b) => a!.Equals(b), static a => a!.GetHashCode(), f, seed, iter, time, threads, print); } } - public bool NotFaster => Slower > Faster || Median.Median < 0.0; + /// Check an Equals and GetHashCode are consistent. + /// The sample input data generator. + /// The equality comparer to test. + /// Optionally define compared and ignored fields to check equality includes/excludes them. Changing a compared field must make two equal instances unequal; changing an ignored field must keep them equal. A field that affects equality but is not declared is detected as a failure. + /// The initial seed to use for the first iteration. + /// The number of iterations to run in the sample (default 100). + /// The number of seconds to run the sample. + /// The number of threads to run the sample on (default number logical CPUs). + /// A function to convert the input data to a string for error reporting (default Check.Print). + public static void Equality(this Gen gen, IEqualityComparer comparer, Func, EqualityFields>? fields = null, string? seed = null, long iter = -1, int time = -1, int threads = -1, + Func<(T, T), string>? print = null) + { + if (iter == -1) iter = Iter; + if (iter > 1) iter /= 2; + if (time == -1) time = Time; + if (time > 1) time /= 2; - public bool Add(long faster, long slower) + gen.Clone().Sample((t1, t2) => + comparer.Equals(t1, t2) && comparer.Equals(t2, t1) && comparer.GetHashCode(t1!) == comparer.GetHashCode(t2!) + , null, seed, iter, time, threads, print); + + gen.Select(gen).Sample((t1, t2) => + { + bool equal = comparer.Equals(t1, t2); + return (!equal && !comparer.Equals(t2, t1)) + || (equal && comparer.Equals(t2, t1) && comparer.GetHashCode(t1!) == comparer.GetHashCode(t2!)); + }, null, seed, iter, time, threads, print); + + if (fields is not null) + { + var f = fields(new EqualityFields()); + if (f.ComparedFields.Count > 0 || f.IgnoredFields.Count > 0) + SampleEqualityFields(gen, comparer.Equals, t => comparer.GetHashCode(t!), f, seed, iter, time, threads, print); + } + } + + sealed class GenFieldApplies(Gen>[] gens) : Gen[]> { - lock (Median) + public override FieldApply[] Generate(PCG pcg, Size? min, out Size size) { - if (completed) return false; - if (faster < FasterMin) FasterMin = faster; - if (slower < SlowerMin) SlowerMin = slower; - double ratio; - if (slower > faster) - { - ratio = (double)(slower - faster) / slower; - Faster++; - } - else if (slower != faster) + var array = new FieldApply[gens.Length]; + size = new Size(0); + for (int i = 0; i < gens.Length; i++) { - ratio = (double)(slower - faster) / faster; - Slower++; + array[i] = gens[i].Generate(pcg, min, out var s); + size.Add(s); + if (Size.IsLessThan(min, size)) return array; } - else - { - ratio = 0d; - } - Median.Add(ratio); - if (SigmaSquared < Limit) return false; - completed = true; - return true; + return array; } } - public override string ToString() + static void SampleEqualityFields(Gen gen, Func equals, Func hash, EqualityFields fields, + string? seed, long iter, int time, int threads, Func<(T, T), string>? print) { - var times = Median.Median >= 0.0 ? 1 / (1 - Median.Median) : 1 + Median.Median; - var q1Times = Median.Q1 >= 0.0 ? 1 / (1 - Median.Q1) : 1 + Median.Q1; - var q3Times = Median.Q3 >= 0.0 ? 1 / (1 - Median.Q3) : 1 + Median.Q3; - var faster = Median.Median >= 0.0 ? "faster" : "slower"; - if (Median.Median < 0.0) - { - times = 1 / times; - (q1Times, q3Times) = (1 / q3Times, 1 / q1Times); + int comparedCount = fields.ComparedFields.Count, ignoredCount = fields.IgnoredFields.Count; + var all = new EqualityField[comparedCount + ignoredCount]; + for (int i = 0; i < comparedCount; i++) all[i] = fields.ComparedFields[i]; + for (int i = 0; i < ignoredCount; i++) all[comparedCount + i] = fields.IgnoredFields[i]; + var applyGens = Array.ConvertAll(all, static f => f.ApplyGen()); + Func<(T, T), string> basePrint = print ?? Print; + string Print2((T, T, FieldApply[]) x) => basePrint((x.Item1, x.Item2)); + Gen.Select(gen.Select(gen), new GenFieldApplies(applyGens), static (pair, applies) => (pair.Item1, pair.Item2, applies)) + .Sample(x => + { + var (a, b, applies) = x; + // Normalise every declared field to the same value on both instances. If the declared fields + // fully cover equality the instances must now be equal; a missing field leaves them unequal. + for (int i = 0; i < applies.Length; i++) + { + a = applies[i].SetPrimary(a); + b = applies[i].SetPrimary(b); + } + if (!equals(a, b) || hash(a) != hash(b)) + throw new CsCheckException("Equality or GetHashCode is affected by a field that is not declared as compared or ignored."); + // Change one field at a time on b. Compared fields must break equality; ignored fields must not. + for (int i = 0; i < applies.Length; i++) + { + var bAlt = applies[i].SetAlt(b); + bool eq = equals(a, bAlt); + if (i < comparedCount) + { + b = applies[i].SetPrimary(b); // restore for in-place (mutable) setters + if (eq) + throw new CsCheckException($"Compared field '{applies[i].Name}' does not affect equality: changing it left the instances equal."); + } + else + { +bool hashEq = hash(a) == hash(bAlt); +b = applies[i].SetPrimary(b); // restore for in-place (mutable) setters +if (!eq) + throw new CsCheckException($"Ignored field '{applies[i].Name}' affects equality: changing it made the instances unequal."); +if (!hashEq) + throw new CsCheckException($"Ignored field '{applies[i].Name}' affects GetHashCode: changing it changed the hash code while the instances remained equal."); + } + } + return true; + }, null, seed, iter, time, threads, (Func<(T, T, FieldApply[]), string>)Print2); + } +} +readonly struct FieldApply(string name, Func setPrimary, Func setAlt) +{ + public readonly string Name = name; + public readonly Func SetPrimary = setPrimary; + public readonly Func SetAlt = setAlt; +} + +// Generates two values that differ by the comparer. Retries internally so a low-cardinality field does not +// waste sample iterations, and throws a clear field-named error if the generator cannot produce a distinct pair. +sealed class GenDistinctPair(Gen gen, IEqualityComparer comparer, string name) : Gen<(V, V)> +{ + public override (V, V) Generate(PCG pcg, Size? min, out Size size) + { + int i = Check.WhereLimit; + while (i-- > 0) + { + var v1 = gen.Generate(pcg, min, out size); + if (Size.IsLessThan(min, size)) return default!; + var v2 = gen.Generate(pcg, min, out var s); + size.Add(s); + if (Size.IsLessThan(min, size)) return default!; + if (!comparer.Equals(v1, v2)) return (v1, v2); } - var (timeString, timeUnit) = TimeFormat((double)Math.Min(FasterMin, SlowerMin) / repeat); - var result = $"{Median.Median:P2}[{Median.Q1:P2}..{Median.Q3:P2}] {times:#0.00}x[{q1Times:#0.00}x..{q3Times:#0.00}x] {faster}"; - if (double.IsNaN(Median.Median)) result = $"Time resolution too small try using repeat.\n{result}"; - else if ((Median.Median >= 0.0) != (Faster > Slower)) result = $"Inconsistent result try using repeat or increasing sigma.\n{result}"; - result = $"{result}, sigma = {Math.Sqrt(SigmaSquared):#0.0} ({Faster:#,0} vs {Slower:#,0}), min = {timeString((double)FasterMin / repeat)}{timeUnit} vs {timeString((double)SlowerMin / repeat)}{timeUnit}"; - if (Check.IsDebug) result += " - DEBUG MODE - DO NOT TRUST THESE RESULTS"; - return result; - } - - private static (Func, string) TimeFormat(double maxValue) => - (maxValue * 1000 / Stopwatch.Frequency) switch - { - >= 1000000 => (d => (d / Stopwatch.Frequency).ToString("###0"), "s"), - >= 100000 => (d => (d / Stopwatch.Frequency).ToString("###0.#"), "s"), - >= 10000 => (d => (d / Stopwatch.Frequency).ToString("###0.##"), "s"), - >= 1000 => (d => (d * 1000 / Stopwatch.Frequency).ToString("###0"), "ms"), - >= 100 => (d => (d * 1000 / Stopwatch.Frequency).ToString("###0.#"), "ms"), - >= 10 => (d => (d * 1000 / Stopwatch.Frequency).ToString("###0.##"), "ms"), - >= 1 => (d => (d * 1000 / Stopwatch.Frequency).ToString("###0.###"), "ms"), - >= 0.1 => (d => (d * 1_000_000 / Stopwatch.Frequency).ToString("###0.#"), "μs"), - >= 0.01 => (d => (d * 1_000_000 / Stopwatch.Frequency).ToString("###0.##"), "μs"), - >= 0.001 => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0"), "ns"), - >= 0.0001 => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0.#"), "ns"), - >= 0.00001 => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0.##"), "ns"), - >= 0.000001 => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0.###"), "ns"), - _ => (d => (d * 1_000_000_000 / Stopwatch.Frequency).ToString("###0.####"), "ns"), - }; + throw new CsCheckException($"Field '{name}' generator did not produce two distinct values after {Check.WhereLimit} attempts."); + } +} + +abstract class EqualityField +{ + private protected EqualityField(string name) => Name = name; + internal string Name { get; } + internal abstract Gen> ApplyGen(); +} + +sealed class EqualityField : EqualityField +{ + readonly Func set; + readonly Gen gen; + readonly IEqualityComparer comparer; - public void Output(Action output) => output(ToString()); -} \ No newline at end of file + internal EqualityField(string name, Func set, Gen gen, IEqualityComparer? comparer) : base(name) + { + this.set = set; + this.gen = gen; + this.comparer = comparer ?? EqualityComparer.Default; + } + + internal override Gen> ApplyGen() + { + var name = Name; + var s = set; + return new GenDistinctPair(gen, comparer, name) + .Select(t => new FieldApply(name, x => s(x, t.Item1), x => s(x, t.Item2))); + } +} + +/// A builder for the compared and ignored fields tested by . Each field takes a setter (a functional with setter or an in-place ) and a value generator. To test a compared field the generator must be able to produce two meaningfully-different values for it; for a field with normalized equality (rounding, tolerance, case) either generate values that stay distinct once set or pass a matching comparer. +public sealed class EqualityFields +{ + internal readonly List> ComparedFields = []; + internal readonly List> IgnoredFields = []; + + /// Add a field that is expected to be included in equality. Changing it must make two equal instances unequal. + /// A functional setter that returns the instance with the field value set (e.g. a record with expression). + /// The generator for the field value. + /// When two field values are considered the same for equality (default EqualityComparer.Default). For a field whose setter transforms the value (e.g. rounds or clamps), pass a comparer that reflects that transform so the two generated values stay distinct once set. + /// The field name for failure messages (defaults to the setter expression). + public EqualityFields Compared(Func set, Gen gen, IEqualityComparer? comparer = null, [CallerArgumentExpression(nameof(set))] string name = "") + { + ComparedFields.Add(new EqualityField(name, set, gen, comparer)); + return this; + } + + /// Add a field that is expected to be included in equality. Changing it must make two equal instances unequal. + /// An in-place setter that mutates the field value on the instance. + /// The generator for the field value. + /// When two field values are considered the same for equality (default EqualityComparer.Default). For a field whose setter transforms the value (e.g. rounds or clamps), pass a comparer that reflects that transform so the two generated values stay distinct once set. + /// The field name for failure messages (defaults to the setter expression). + public EqualityFields Compared(Action set, Gen gen, IEqualityComparer? comparer = null, [CallerArgumentExpression(nameof(set))] string name = "") + { + ComparedFields.Add(new EqualityField(name, (t, v) => { set(t, v); return t; }, gen, comparer)); + return this; + } + + /// Add a field that is expected to be excluded from equality. Changing it must keep two equal instances equal. + /// A functional setter that returns the instance with the field value set (e.g. a record with expression). + /// The generator for the field value. + /// When two field values are considered the same for equality (default EqualityComparer.Default). For a field whose setter transforms the value (e.g. rounds or clamps), pass a comparer that reflects that transform so the two generated values stay distinct once set. + /// The field name for failure messages (defaults to the setter expression). + public EqualityFields Ignored(Func set, Gen gen, IEqualityComparer? comparer = null, [CallerArgumentExpression(nameof(set))] string name = "") + { + IgnoredFields.Add(new EqualityField(name, set, gen, comparer)); + return this; + } + + /// Add a field that is expected to be excluded from equality. Changing it must keep two equal instances equal. + /// An in-place setter that mutates the field value on the instance. + /// The generator for the field value. + /// When two field values are considered the same for equality (default EqualityComparer.Default). For a field whose setter transforms the value (e.g. rounds or clamps), pass a comparer that reflects that transform so the two generated values stay distinct once set. + /// The field name for failure messages (defaults to the setter expression). + public EqualityFields Ignored(Action set, Gen gen, IEqualityComparer? comparer = null, [CallerArgumentExpression(nameof(set))] string name = "") + { + IgnoredFields.Add(new EqualityField(name, (t, v) => { set(t, v); return t; }, gen, comparer)); + return this; + } +} diff --git a/README.md b/README.md index 18908f1..5b3b91d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ CsCheck also has functionality to make multiple types of testing simple and fast - [Causal profiling](#Causal-profiling) - [Regression testing](#Regression-testing) - [Performance testing](#Performance-testing) +- [Equality testing](#Equality-testing) - [Debug utilities](#Debug-utilities) - [Configuration](#Configuration) - [Development](#Development) @@ -506,6 +507,44 @@ Standard Output Messages: 10.94%[-3.27%..25.81%] 1.12x[0.97x..1.35x] faster, sigma = 10.0 (442 vs 190), min = 7.082ns vs 7.332ns ``` +## Equality testing + +Equality checks that a type's `Equals`, `IEquatable` and `GetHashCode` are consistent for generated values: equal values compare equal both ways and share a hash code, while unequal values disagree. + +You can also declare the fields in the equality contract. **Compared** fields must change equality; **Ignored** fields must not. CsCheck also checks completeness: if an undeclared field affects equality, it fails. + +Setters can be record `with` expressions or in-place `Action`s. For declared fields, failure messages use the setter expression name. For normalized equality (rounding, tolerance, case), use a matching `IEqualityComparer` (or a generator that still produces distinct values after setting). + +You can pass an `IEqualityComparer` as the first argument to test a comparer directly instead of the type's own equality. + +### Account Equality +```csharp +[Test] +public void Equality_Int() +{ + Check.Equality(Gen.Int); +} + +record Account(int Id, string Note) // equality is on Id only, Note is ignored +{ + public virtual bool Equals(Account? other) => other is not null && Id == other.Id; + public override int GetHashCode() => Id.GetHashCode(); +} + +[Test] +public void Equality_Fields() +{ + var gen = + from id in Gen.Int + from note in Gen.String + select new Account(id, note); + gen.Equality(f => f + .Compared((a, v) => a with { Id = v }, Gen.Int) + .Ignored((a, v) => a with { Note = v }, Gen.String) + ); +} +``` + ## Debug utilities The Dbg module is a set of utilities to collect, count and output debug info, time, classify generators, define and remotely call functions, and perform in code regression during testing. diff --git a/Tests/CheckTests.cs b/Tests/CheckTests.cs index c785a89..c1881b4 100644 --- a/Tests/CheckTests.cs +++ b/Tests/CheckTests.cs @@ -325,6 +325,134 @@ public void Equality_String() Check.Equality(Gen.String); } + sealed record Account(int Id, string Note) + { + public bool Equals(Account? other) => other is not null && Id == other.Id; + public override int GetHashCode() => Id.GetHashCode(); + } + + [Test] + public void Equality_Fields() + { + var gen = + from id in Gen.Int + from note in Gen.String + select new Account(id, note); + gen.Equality(f => f + .Compared((a, v) => a with { Id = v }, Gen.Int) + .Ignored((a, v) => a with { Note = v }, Gen.String)); + } + + static Gen GenAccount => + from id in Gen.Int + from note in Gen.String + select new Account(id, note); + + [Test] + public void Equality_Fields_Detects_Ignored_Declared_As_Compared() + { + Assert.Throws(() => GenAccount.Equality(f => f + .Compared((a, v) => a with { Id = v }, Gen.Int) + .Compared((a, v) => a with { Note = v }, Gen.String))); + } + + [Test] + public void Equality_Fields_Detects_Compared_Declared_As_Ignored() + { + Assert.Throws(() => GenAccount.Equality(f => f + .Ignored((a, v) => a with { Id = v }, Gen.Int) + .Ignored((a, v) => a with { Note = v }, Gen.String))); + } + + [Test] + public void Equality_Fields_Detects_Missing_Field() + { + Assert.Throws(() => GenAccount.Equality(f => f + .Ignored((a, v) => a with { Note = v }, Gen.String))); + } + + [Test] + public void Equality_Fields_Detects_Non_Varying_Gen() + { + Assert.Throws(() => GenAccount.Equality(f => f + .Compared((a, v) => a with { Id = v }, Gen.Const(0)))); + } + + [Test] + public void Equality_Fields_Mutable() + { + var gen = Gen.Select(Gen.Int, Gen.String, (id, note) => new MutableAccount(id, note)); + gen.Equality(f => f + .Compared((a, v) => a.Id = v, Gen.Int) + .Ignored((a, v) => a.Note = v, Gen.String)); + } + + [Test] + public void Equality_Fields_Nested() + { + var gen = + from name in Gen.String + from house in Gen.Int + from street in Gen.String + select new Person(name, new Address(house, street)); + gen.Equality(f => f + .Compared((p, v) => p with { Name = v }, Gen.String) + .Compared((p, v) => p with { Addr = p.Addr with { House = v } }, Gen.Int) + .Ignored((p, v) => p with { Addr = p.Addr with { Street = v } }, Gen.String)); + } + + [Test] + public void Equality_Fields_Comparer() + { + GenAccount.Equality(new AccountNoteComparer(), f => f + .Compared((a, v) => a with { Note = v }, Gen.String) + .Ignored((a, v) => a with { Id = v }, Gen.Int)); + } + + [Test] + public void Equality_Fields_Normalized() + { + var gen = Gen.Int[0, 1000].Select(x => new Rounded(x)); + gen.Equality(f => f + .Compared((r, v) => r with { Raw = v }, Gen.Int[0, 1000], new RoundToTenComparer())); + } + + sealed class MutableAccount(int id, string note) + { + public int Id = id; + public string Note = note; + public override bool Equals(object? obj) => obj is MutableAccount m && m.Id == Id; + public override int GetHashCode() => Id.GetHashCode(); + } + + sealed record Address(int House, string Street); + + sealed record Person(string Name, Address Addr) + { + public bool Equals(Person? other) => other is not null && Name == other.Name && Addr.House == other.Addr.House; + public override int GetHashCode() => HashCode.Combine(Name, Addr.House); + } + + sealed record Rounded(int Raw) + { + int Bucket => (Raw + 5) / 10 * 10; + public bool Equals(Rounded? other) => other is not null && Bucket == other.Bucket; + public override int GetHashCode() => Bucket.GetHashCode(); + } + + sealed class AccountNoteComparer : IEqualityComparer + { + public bool Equals(Account? a, Account? b) => a is null ? b is null : b is not null && a.Note == b.Note; + public int GetHashCode(Account a) => a.Note.GetHashCode(); + } + + sealed class RoundToTenComparer : IEqualityComparer + { + static int Round(int v) => (v + 5) / 10 * 10; + public bool Equals(int a, int b) => Round(a) == Round(b); + public int GetHashCode(int v) => Round(v).GetHashCode(); + } + [Test] public void Enqueue_Faster_Than_Median() {