From 5054499a529472fe544f088e4f84a4c0c57cc6df Mon Sep 17 00:00:00 2001 From: makszimin Date: Sat, 13 Sep 2025 22:03:18 +0200 Subject: [PATCH 1/7] add WithoutStrictOrdering for IJsonAssertionOptions --- .../Common/JTokenExtensions.cs | 18 +++++ .../IJsonAssertionOptions.cs | 5 ++ .../JTokenDifferentiator.cs | 9 +++ .../JsonAssertionOptions.cs | 14 +++- .../WithoutStrictOrderingSpecs.cs | 69 +++++++++++++++++++ 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 Src/FluentAssertions.Json/Common/JTokenExtensions.cs create mode 100644 Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs diff --git a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs new file mode 100644 index 00000000..b6006273 --- /dev/null +++ b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs @@ -0,0 +1,18 @@ +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace FluentAssertions.Json.Common +{ + internal static class JTokenExtensions + { + public static JToken Normalize(this JToken token) + { + return token switch + { + JObject obj => new JObject(obj.Properties().OrderBy(p => p.Name).Select(p => new JProperty(p.Name, Normalize(p.Value)))), + JArray array => new JArray(array.Select(Normalize).OrderBy(x => x.ToString(Newtonsoft.Json.Formatting.None))), + _ => token + }; + } + } +} diff --git a/Src/FluentAssertions.Json/IJsonAssertionOptions.cs b/Src/FluentAssertions.Json/IJsonAssertionOptions.cs index ef0c2d38..6b64d181 100644 --- a/Src/FluentAssertions.Json/IJsonAssertionOptions.cs +++ b/Src/FluentAssertions.Json/IJsonAssertionOptions.cs @@ -16,5 +16,10 @@ public interface IJsonAssertionOptions /// The assertion to execute when the predicate is met. /// IJsonAssertionRestriction Using(Action> action); + + /// + /// Configures the JSON assertion to ignore the order of elements in arrays or collections during comparison, allowing for equivalency checks regardless of element sequence. + /// + IJsonAssertionOptions WithoutStrictOrdering(); } } diff --git a/Src/FluentAssertions.Json/JTokenDifferentiator.cs b/Src/FluentAssertions.Json/JTokenDifferentiator.cs index f5c7cf77..bb1900d9 100644 --- a/Src/FluentAssertions.Json/JTokenDifferentiator.cs +++ b/Src/FluentAssertions.Json/JTokenDifferentiator.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using FluentAssertions.Execution; +using FluentAssertions.Json.Common; using Newtonsoft.Json.Linq; namespace FluentAssertions.Json @@ -11,12 +12,14 @@ internal class JTokenDifferentiator private readonly bool ignoreExtraProperties; private readonly Func, IJsonAssertionOptions> config; + private readonly JsonAssertionOptions options; public JTokenDifferentiator(bool ignoreExtraProperties, Func, IJsonAssertionOptions> config) { this.ignoreExtraProperties = ignoreExtraProperties; this.config = config; + this.options = (JsonAssertionOptions)config(new JsonAssertionOptions()); } public Difference FindFirstDifference(JToken actual, JToken expected) @@ -38,6 +41,12 @@ public Difference FindFirstDifference(JToken actual, JToken expected) return new Difference(DifferenceKind.ExpectedIsNull, path); } + if (options.IsStrictOrdering is false) + { + actual = actual.Normalize(); + expected = expected.Normalize(); + } + return FindFirstDifference(actual, expected, path); } diff --git a/Src/FluentAssertions.Json/JsonAssertionOptions.cs b/Src/FluentAssertions.Json/JsonAssertionOptions.cs index a331127a..f431a073 100644 --- a/Src/FluentAssertions.Json/JsonAssertionOptions.cs +++ b/Src/FluentAssertions.Json/JsonAssertionOptions.cs @@ -4,17 +4,27 @@ namespace FluentAssertions.Json { /// - /// Represents the run-time type-specific behavior of a JSON structural equivalency assertion. It is the equivalent of + /// Represents the run-time type-specific behavior of a JSON structural equivalency assertion. It is the equivalent of /// public sealed class JsonAssertionOptions : EquivalencyOptions, IJsonAssertionOptions { + internal JsonAssertionOptions() { } + public JsonAssertionOptions(EquivalencyOptions equivalencyAssertionOptions) : base(equivalencyAssertionOptions) { - } + + public bool IsStrictOrdering { get; private set; } = true; + public new IJsonAssertionRestriction Using(Action> action) { return new JsonAssertionRestriction(base.Using(action)); } + + public new IJsonAssertionOptions WithoutStrictOrdering() + { + IsStrictOrdering = false; + return this; + } } } diff --git a/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs b/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs new file mode 100644 index 00000000..37fc415c --- /dev/null +++ b/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Xunit; +using Xunit.Sdk; + +namespace FluentAssertions.Json.Specs +{ + public class WithoutStrictOrderingSpecs + { + [Theory] + [MemberData(nameof(Should_HandleJToken_WhenNeedToIgnoreOrdering_SampleData))] + public void Should_HandleJToken_WhenNeedToIgnoreOrdering(string json1, string json2) + { + // Arrange + var j1 = JToken.Parse(json1); + var j2 = JToken.Parse(json2); + + // Act + j1.Should().BeEquivalentTo(j2, opt => opt.WithoutStrictOrdering()); + + // Assert + } + + [Theory] + [MemberData(nameof(Should_DoNotHandleJToken_WhenNoNeedToIgnoreOrdering_SampleData))] + public void Should_DoNotHandleJToken_WhenNoNeedToIgnoreOrdering(string json1, string json2) + { + // Arrange + var j1 = JToken.Parse(json1); + var j2 = JToken.Parse(json2); + + // Act + var action = new Func>(() => j1.Should().BeEquivalentTo(j2)); + + // Assert + action.Should().Throw(); + } + + public static IEnumerable Should_DoNotHandleJToken_WhenNoNeedToIgnoreOrdering_SampleData() + { + yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }; + yield return new object[] { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }; + yield return new object[] + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" + }; + } + + public static IEnumerable Should_HandleJToken_WhenNeedToIgnoreOrdering_SampleData() + { + yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }; + yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[1,2,3]}" }; + yield return new object[] { @"{""type"":2,""name"":""b""}", @"{""name"":""b"",""type"":2}" }; + yield return new object[] { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }; + yield return new object[] + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" + }; + yield return new object[] + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""name"":""a"",""type"":1},{""type"":2,""name"":""b""}]}" + }; + } + } +} From 8e6e5911aa8120ae6bfa9b0063f2f5a83530e7c7 Mon Sep 17 00:00:00 2001 From: maximiliysiss Date: Sat, 13 Sep 2025 22:19:32 +0200 Subject: [PATCH 2/7] correct api --- Src/FluentAssertions.Json/JsonAssertionOptions.cs | 2 +- .../ApprovedApi/FluentAssertions.Json/net47.verified.txt | 2 ++ .../FluentAssertions.Json/netstandard2.0.verified.txt | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Src/FluentAssertions.Json/JsonAssertionOptions.cs b/Src/FluentAssertions.Json/JsonAssertionOptions.cs index f431a073..97a8f5f1 100644 --- a/Src/FluentAssertions.Json/JsonAssertionOptions.cs +++ b/Src/FluentAssertions.Json/JsonAssertionOptions.cs @@ -14,7 +14,7 @@ public JsonAssertionOptions(EquivalencyOptions equivalencyAssertionOptions) : { } - public bool IsStrictOrdering { get; private set; } = true; + internal bool IsStrictOrdering { get; private set; } = true; public new IJsonAssertionRestriction Using(Action> action) { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/net47.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/net47.verified.txt index 74b35143..7b659d2f 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/net47.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/net47.verified.txt @@ -4,6 +4,7 @@ namespace FluentAssertions.Json public interface IJsonAssertionOptions { FluentAssertions.Json.IJsonAssertionRestriction Using(System.Action> action); + FluentAssertions.Json.IJsonAssertionOptions WithoutStrictOrdering(); } public interface IJsonAssertionRestriction { @@ -50,6 +51,7 @@ namespace FluentAssertions.Json { public JsonAssertionOptions(FluentAssertions.Equivalency.EquivalencyOptions equivalencyAssertionOptions) { } public FluentAssertions.Json.IJsonAssertionRestriction Using(System.Action> action) { } + public FluentAssertions.Json.IJsonAssertionOptions WithoutStrictOrdering() { } } public sealed class JsonAssertionRestriction : FluentAssertions.Json.IJsonAssertionRestriction { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/netstandard2.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/netstandard2.0.verified.txt index 74b35143..7b659d2f 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/netstandard2.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/netstandard2.0.verified.txt @@ -4,6 +4,7 @@ namespace FluentAssertions.Json public interface IJsonAssertionOptions { FluentAssertions.Json.IJsonAssertionRestriction Using(System.Action> action); + FluentAssertions.Json.IJsonAssertionOptions WithoutStrictOrdering(); } public interface IJsonAssertionRestriction { @@ -50,6 +51,7 @@ namespace FluentAssertions.Json { public JsonAssertionOptions(FluentAssertions.Equivalency.EquivalencyOptions equivalencyAssertionOptions) { } public FluentAssertions.Json.IJsonAssertionRestriction Using(System.Action> action) { } + public FluentAssertions.Json.IJsonAssertionOptions WithoutStrictOrdering() { } } public sealed class JsonAssertionRestriction : FluentAssertions.Json.IJsonAssertionRestriction { From 424b09eac277cb7b102fb894435e095a600b1d37 Mon Sep 17 00:00:00 2001 From: makszimin Date: Sun, 28 Sep 2025 14:06:28 +0200 Subject: [PATCH 3/7] rename prop IsStrictlyOrdered, tests + reorder test's methods --- .../Common/JTokenExtensions.cs | 5 ++ .../JTokenDifferentiator.cs | 2 +- .../JsonAssertionOptions.cs | 4 +- .../WithoutStrictOrderingSpecs.cs | 58 +++++++++---------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs index b6006273..229ff5db 100644 --- a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs +++ b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs @@ -5,6 +5,11 @@ namespace FluentAssertions.Json.Common { internal static class JTokenExtensions { + /// + /// Recursively sorts the properties of JObject instances by name and + /// the elements of JArray instances by their string representation, + /// producing a normalized JToken for consistent comparison + /// public static JToken Normalize(this JToken token) { return token switch diff --git a/Src/FluentAssertions.Json/JTokenDifferentiator.cs b/Src/FluentAssertions.Json/JTokenDifferentiator.cs index bb1900d9..38293bb6 100644 --- a/Src/FluentAssertions.Json/JTokenDifferentiator.cs +++ b/Src/FluentAssertions.Json/JTokenDifferentiator.cs @@ -41,7 +41,7 @@ public Difference FindFirstDifference(JToken actual, JToken expected) return new Difference(DifferenceKind.ExpectedIsNull, path); } - if (options.IsStrictOrdering is false) + if (!options.IsStrictlyOrdered) { actual = actual.Normalize(); expected = expected.Normalize(); diff --git a/Src/FluentAssertions.Json/JsonAssertionOptions.cs b/Src/FluentAssertions.Json/JsonAssertionOptions.cs index 97a8f5f1..f98a9ba6 100644 --- a/Src/FluentAssertions.Json/JsonAssertionOptions.cs +++ b/Src/FluentAssertions.Json/JsonAssertionOptions.cs @@ -14,7 +14,7 @@ public JsonAssertionOptions(EquivalencyOptions equivalencyAssertionOptions) : { } - internal bool IsStrictOrdering { get; private set; } = true; + internal bool IsStrictlyOrdered { get; private set; } = true; public new IJsonAssertionRestriction Using(Action> action) { @@ -23,7 +23,7 @@ public JsonAssertionOptions(EquivalencyOptions equivalencyAssertionOptions) : public new IJsonAssertionOptions WithoutStrictOrdering() { - IsStrictOrdering = false; + IsStrictlyOrdered = false; return this; } } diff --git a/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs b/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs index 37fc415c..9b803ccc 100644 --- a/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs +++ b/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs @@ -9,61 +9,61 @@ namespace FluentAssertions.Json.Specs public class WithoutStrictOrderingSpecs { [Theory] - [MemberData(nameof(Should_HandleJToken_WhenNeedToIgnoreOrdering_SampleData))] - public void Should_HandleJToken_WhenNeedToIgnoreOrdering(string json1, string json2) + [MemberData(nameof(When_ignoring_ordering_BeEquivalentTo_should_succeed_sample_data))] + public void When_ignoring_ordering_BeEquivalentTo_should_succeed(string subject, string expectation) { // Arrange - var j1 = JToken.Parse(json1); - var j2 = JToken.Parse(json2); + var subjectJToken = JToken.Parse(subject); + var expectationJToken = JToken.Parse(expectation); // Act - j1.Should().BeEquivalentTo(j2, opt => opt.WithoutStrictOrdering()); + subjectJToken.Should().BeEquivalentTo(expectationJToken, opt => opt.WithoutStrictOrdering()); // Assert } - [Theory] - [MemberData(nameof(Should_DoNotHandleJToken_WhenNoNeedToIgnoreOrdering_SampleData))] - public void Should_DoNotHandleJToken_WhenNoNeedToIgnoreOrdering(string json1, string json2) - { - // Arrange - var j1 = JToken.Parse(json1); - var j2 = JToken.Parse(json2); - - // Act - var action = new Func>(() => j1.Should().BeEquivalentTo(j2)); - - // Assert - action.Should().Throw(); - } - - public static IEnumerable Should_DoNotHandleJToken_WhenNoNeedToIgnoreOrdering_SampleData() + public static IEnumerable When_ignoring_ordering_BeEquivalentTo_should_succeed_sample_data() { yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }; + yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[1,2,3]}" }; + yield return new object[] { @"{""type"":2,""name"":""b""}", @"{""name"":""b"",""type"":2}" }; yield return new object[] { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }; yield return new object[] { @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" }; + yield return new object[] + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""name"":""a"",""type"":1},{""type"":2,""name"":""b""}]}" + }; } - public static IEnumerable Should_HandleJToken_WhenNeedToIgnoreOrdering_SampleData() + [Theory] + [MemberData(nameof(When_not_ignoring_ordering_BeEquivalentTo_should_throw_sample_data))] + public void When_not_ignoring_ordering_BeEquivalentTo_should_throw(string subject, string expectation) + { + // Arrange + var subjectJToken = JToken.Parse(subject); + var expectationJToken = JToken.Parse(expectation); + + // Act + var action = new Func>(() => subjectJToken.Should().BeEquivalentTo(expectationJToken)); + + // Assert + action.Should().Throw(); + } + + public static IEnumerable When_not_ignoring_ordering_BeEquivalentTo_should_throw_sample_data() { yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }; - yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[1,2,3]}" }; - yield return new object[] { @"{""type"":2,""name"":""b""}", @"{""name"":""b"",""type"":2}" }; yield return new object[] { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }; yield return new object[] { @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" }; - yield return new object[] - { - @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", - @"{""vals"":[{""name"":""a"",""type"":1},{""type"":2,""name"":""b""}]}" - }; } } } From b45cc22353a26c7cb7280cb4cb5c79c91db14d8a Mon Sep 17 00:00:00 2001 From: makszimin Date: Sun, 28 Sep 2025 14:57:08 +0200 Subject: [PATCH 4/7] add paragraph about WithoutStrictOrdering in README.md --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1da4d759..68fec189 100644 --- a/README.md +++ b/README.md @@ -56,4 +56,22 @@ var expected = JToken.Parse(@"{ ""value"" : 1.4 }"); actual.Should().BeEquivalentTo(expected, options => options .Using(d => d.Subject.Should().BeApproximately(d.Expectation, 0.1)) .WhenTypeIs()); -``` \ No newline at end of file +``` + +Also, there is `WithoutStrictOrdering()` which allows you to compare JSON arrays while ignoring the order of their elements. +This is useful when the sequence of items is not important for your test scenario. When applied, assertions like `BeEquivalentTo()` will +succeed as long as the arrays contain the same elements, regardless of their order. + +Example: + +```c# +using FluentAssertions; +using FluentAssertions.Json; +using Newtonsoft.Json.Linq; + +... +var actual = JToken.Parse(@"{ ""array"" : [1, 2, 3] }"); +var expected = JToken.Parse(@"{ ""array"" : [3, 2, 1] }"); +actual.Should().BeEquivalentTo(expected, options => options + .WithoutStrictOrdering()); +``` From ec7dd185ba9348fde7d3f9aabd7c21c542280a97 Mon Sep 17 00:00:00 2001 From: makszimin Date: Fri, 3 Oct 2025 18:17:21 +0200 Subject: [PATCH 5/7] using TheoryData + adding JTokenComparer instead of compare like ToString --- .../Common/JTokenExtensions.cs | 76 +++++++++++- .../JTokenComparerSpecs.cs | 114 ++++++++++++++++++ .../WithoutStrictOrderingSpecs.cs | 41 ++++--- 3 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs diff --git a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs index 229ff5db..74b3f56d 100644 --- a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs +++ b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Linq; using Newtonsoft.Json.Linq; @@ -5,6 +7,8 @@ namespace FluentAssertions.Json.Common { internal static class JTokenExtensions { + private static readonly JTokenComparer Comparer = new(); + /// /// Recursively sorts the properties of JObject instances by name and /// the elements of JArray instances by their string representation, @@ -15,9 +19,79 @@ public static JToken Normalize(this JToken token) return token switch { JObject obj => new JObject(obj.Properties().OrderBy(p => p.Name).Select(p => new JProperty(p.Name, Normalize(p.Value)))), - JArray array => new JArray(array.Select(Normalize).OrderBy(x => x.ToString(Newtonsoft.Json.Formatting.None))), + JArray array => new JArray(array.Select(Normalize).OrderBy(x => x, Comparer)), _ => token }; } + + private sealed class JTokenComparer : IComparer + { + public int Compare(JToken x, JToken y) + { + if (ReferenceEquals(x, y)) + return 0; + + if (x is null) + return -1; + + if (y is null) + return 1; + + var typeComparison = x.Type.CompareTo(y.Type); + if (typeComparison != 0) + return typeComparison; + + return x switch + { + JArray a => Compare(a, (JArray)y), + JObject o => Compare(o, (JObject)y), + JProperty p => Compare(p, (JProperty)y), + JValue v => Compare(v, (JValue)y), + _ => string.Compare(x.ToString(), y.ToString(), StringComparison.Ordinal) + }; + } + + private static int Compare(JValue x, JValue y) => Comparer.Default.Compare(x.Value, y.Value); + + private static int Compare(JArray x, JArray y) + { + var countComparison = x.Count.CompareTo(y.Count); + if (countComparison != 0) + return countComparison; + + return x + .Select((t, i) => Comparer.Compare(t, y[i])) + .FirstOrDefault(itemComparison => itemComparison != 0); + } + + private static int Compare(JObject x, JObject y) + { + var countComparison = x.Count.CompareTo(y.Count); + if (countComparison != 0) + return countComparison; + + var xProperties = x.Properties().OrderBy(p => p.Name).ToArray(); + var yProperties = y.Properties().OrderBy(p => p.Name).ToArray(); + + for (var i = 0; i < xProperties.Length; i++) + { + var nameComparison = string.Compare(xProperties[i].Name, yProperties[i].Name, StringComparison.Ordinal); + if (nameComparison != 0) + return nameComparison; + + var valueComparison = Comparer.Compare(xProperties[i].Value, yProperties[i].Value); + if (valueComparison != 0) + return valueComparison; + } + + return 0; + } + + private static int Compare(JProperty x, JProperty y) + { + var nameComparison = string.Compare(x.Name, y.Name, StringComparison.Ordinal); + return nameComparison != 0 ? nameComparison : Comparer.Compare(x.Value, y.Value); + } + } } } diff --git a/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs b/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs new file mode 100644 index 00000000..ef4fe420 --- /dev/null +++ b/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace FluentAssertions.Json.Specs +{ + public class JTokenComparerSpecs + { + private static readonly IComparer Comparer = + Type.GetType("FluentAssertions.Json.Common.JTokenExtensions, FluentAssertions.Json")! + .GetField("Comparer", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)! + .GetValue(null) as IComparer; + + [Fact] + public void Should_return_zero_for_same_reference() + { + // Arrange + var token = JToken.Parse(@"{""a"":1}"); + + // Act & Assert + Comparer.Compare(token, token).Should().Be(0); + } + + [Fact] + public void Should_handle_nulls() + { + // Arrange + var token = JToken.Parse("1"); + + // Act & Assert + Comparer.Compare(null, token).Should().Be(-1); + Comparer.Compare(token, null).Should().Be(1); + Comparer.Compare(null, null).Should().Be(0); + } + + [Fact] + public void Should_compare_different_types() + { + // Arrange + var obj = JToken.Parse(@"{""a"":1}"); + var arr = JToken.Parse("[1]"); + + // Act & Assert + Comparer.Compare(obj, arr).Should().NotBe(0); + } + + [Fact] + public void Should_compare_jvalues() + { + // Arrange + var v1 = new JValue(1); + var v2 = new JValue(2); + + // Act & Assert + Comparer.Compare(v1, v2).Should().Be(-1); + Comparer.Compare(v2, v1).Should().Be(1); + Comparer.Compare(v1, new JValue(1)).Should().Be(0); + } + + [Fact] + public void Should_compare_jarrays_by_count_and_elements() + { + // Arrange + var arr1 = JArray.Parse("[1,2]"); + var arr2 = JArray.Parse("[1,2,3]"); + var arr3 = JArray.Parse("[1,3]"); + var arr4 = JArray.Parse("[1,2,3]"); + + // Act & Assert + Comparer.Compare(arr1, arr2).Should().Be(-1); + Comparer.Compare(arr1, arr3).Should().Be(-1); + Comparer.Compare(arr3, arr1).Should().Be(1); + Comparer.Compare(arr2, arr4).Should().Be(0); + } + + [Fact] + public void Should_compare_jobjects_by_count_and_properties() + { + // Arrange + var obj1 = JObject.Parse(@"{""a"":1}"); + var obj2 = JObject.Parse(@"{""a"":1,""b"":2}"); + var obj3 = JObject.Parse(@"{""a"":2}"); + var obj4 = JObject.Parse(@"{""a"":1,""b"":2}"); + var obj5 = JObject.Parse(@"{""b"":2}"); + + // Act & Assert + Comparer.Compare(obj1, obj2).Should().Be(-1); + Comparer.Compare(obj1, obj3).Should().Be(-1); + Comparer.Compare(obj3, obj1).Should().Be(1); + Comparer.Compare(obj2, obj4).Should().Be(0); + Comparer.Compare(obj1, obj5).Should().Be(-1); + Comparer.Compare(obj5, obj1).Should().Be(1); + } + + [Fact] + public void Should_compare_jproperties_by_name_and_value() + { + // Arrange + var prop1 = new JProperty("a", 1); + var prop2 = new JProperty("b", 1); + var prop3 = new JProperty("a", 2); + var prop4 = new JProperty("a", 1); + + // Act & Assert + Comparer.Compare(prop1, prop2).Should().Be(-1); + Comparer.Compare(prop1, prop3).Should().Be(-1); + Comparer.Compare(prop3, prop1).Should().Be(1); + Comparer.Compare(prop4, prop1).Should().Be(0); + Comparer.Compare(prop2, prop3).Should().Be(1); + Comparer.Compare(prop3, prop2).Should().Be(-1); + } + } +} diff --git a/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs b/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs index 9b803ccc..2cd47d8e 100644 --- a/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs +++ b/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs @@ -22,21 +22,22 @@ public void When_ignoring_ordering_BeEquivalentTo_should_succeed(string subject, // Assert } - public static IEnumerable When_ignoring_ordering_BeEquivalentTo_should_succeed_sample_data() + public static TheoryData When_ignoring_ordering_BeEquivalentTo_should_succeed_sample_data() { - yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }; - yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[1,2,3]}" }; - yield return new object[] { @"{""type"":2,""name"":""b""}", @"{""name"":""b"",""type"":2}" }; - yield return new object[] { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }; - yield return new object[] + return new TheoryData { - @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", - @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" - }; - yield return new object[] - { - @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", - @"{""vals"":[{""name"":""a"",""type"":1},{""type"":2,""name"":""b""}]}" + { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }, + { @"{""ids"":[1,2,3]}", @"{""ids"":[1,2,3]}" }, + { @"{""type"":2,""name"":""b""}", @"{""name"":""b"",""type"":2}" }, + { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }, + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" + }, + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""name"":""a"",""type"":1},{""type"":2,""name"":""b""}]}" + } }; } @@ -55,14 +56,16 @@ public void When_not_ignoring_ordering_BeEquivalentTo_should_throw(string subjec action.Should().Throw(); } - public static IEnumerable When_not_ignoring_ordering_BeEquivalentTo_should_throw_sample_data() + public static TheoryData When_not_ignoring_ordering_BeEquivalentTo_should_throw_sample_data() { - yield return new object[] { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }; - yield return new object[] { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }; - yield return new object[] + return new TheoryData { - @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", - @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" + { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }, + { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }, + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" + } }; } } From bb9e46b2b1f564ce4814a98ebee04df1207d4fc4 Mon Sep 17 00:00:00 2001 From: makszimin Date: Fri, 3 Oct 2025 20:41:42 +0200 Subject: [PATCH 6/7] avoid code duplications --- .../Common/JTokenExtensions.cs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs index 74b3f56d..fd47b68b 100644 --- a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs +++ b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs @@ -70,21 +70,10 @@ private static int Compare(JObject x, JObject y) if (countComparison != 0) return countComparison; - var xProperties = x.Properties().OrderBy(p => p.Name).ToArray(); - var yProperties = y.Properties().OrderBy(p => p.Name).ToArray(); - - for (var i = 0; i < xProperties.Length; i++) - { - var nameComparison = string.Compare(xProperties[i].Name, yProperties[i].Name, StringComparison.Ordinal); - if (nameComparison != 0) - return nameComparison; - - var valueComparison = Comparer.Compare(xProperties[i].Value, yProperties[i].Value); - if (valueComparison != 0) - return valueComparison; - } - - return 0; + return x.Properties() + .OrderBy(p => p.Name) + .Zip(y.Properties().OrderBy(p => p.Name), (px, py) => Compare(px, py)) + .FirstOrDefault(itemComparison => itemComparison != 0); } private static int Compare(JProperty x, JProperty y) From 18910d54d71452b4db1f2fffef7ac4ff78c099c1 Mon Sep 17 00:00:00 2001 From: makszimin Date: Sun, 19 Oct 2025 14:33:09 +0200 Subject: [PATCH 7/7] add JConstructor invariant for comparer --- .../Common/JTokenExtensions.cs | 9 +++- .../JTokenComparerSpecs.cs | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs index fd47b68b..6dd845c5 100644 --- a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs +++ b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs @@ -47,13 +47,20 @@ public int Compare(JToken x, JToken y) JObject o => Compare(o, (JObject)y), JProperty p => Compare(p, (JProperty)y), JValue v => Compare(v, (JValue)y), + JConstructor c => Compare(c, (JConstructor)y), _ => string.Compare(x.ToString(), y.ToString(), StringComparison.Ordinal) }; } private static int Compare(JValue x, JValue y) => Comparer.Default.Compare(x.Value, y.Value); - private static int Compare(JArray x, JArray y) + private static int Compare(JConstructor x, JConstructor y) + { + var nameComparison = string.Compare(x.Name, y.Name, StringComparison.Ordinal); + return nameComparison != 0 ? nameComparison : Compare(x, (JContainer)y); + } + + private static int Compare(JContainer x, JContainer y) { var countComparison = x.Count.CompareTo(y.Count); if (countComparison != 0) diff --git a/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs b/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs index ef4fe420..ab3c086d 100644 --- a/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs +++ b/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs @@ -110,5 +110,49 @@ public void Should_compare_jproperties_by_name_and_value() Comparer.Compare(prop2, prop3).Should().Be(1); Comparer.Compare(prop3, prop2).Should().Be(-1); } + + [Fact] + public void Should_compare_jconstructors_by_name() + { + // Arrange + var ctor1 = new JConstructor("foo", new JValue(1)); + var ctor2 = new JConstructor("bar", new JValue(1)); + + // Act & Assert + Comparer.Compare(ctor1, ctor2).Should().BeGreaterThan(0); // "foo" > "bar" + } + + [Fact] + public void Should_compare_jconstructors_by_argument_count() + { + // Arrange + var ctor1 = new JConstructor("foo", new JValue(1)); + var ctor2 = new JConstructor("foo", new JValue(1), new JValue(2)); + + // Act & Assert + Comparer.Compare(ctor1, ctor2).Should().Be(-1); + } + + [Fact] + public void Should_compare_jconstructors_by_argument_values() + { + // Arrange + var ctor1 = new JConstructor("foo", new JValue(1), new JValue(2)); + var ctor2 = new JConstructor("foo", new JValue(1), new JValue(3)); + + // Act & Assert + Comparer.Compare(ctor1, ctor2).Should().Be(-1); + } + + [Fact] + public void Should_return_zero_for_equal_jconstructors() + { + // Arrange + var ctor1 = new JConstructor("foo", new JValue(1), new JValue(2)); + var ctor2 = new JConstructor("foo", new JValue(1), new JValue(2)); + + // Act & Assert + Comparer.Compare(ctor1, ctor2).Should().Be(0); + } } }