From 5fa6f91133471b1de82cba478311ecb318c0dcba Mon Sep 17 00:00:00 2001 From: 22222 <22222@users.noreply.github.com> Date: Sun, 24 Nov 2019 09:56:05 -0600 Subject: [PATCH] Initial commit --- .editorconfig | 9 + .gitattributes | 2 + .gitignore | 353 +++++++++ CodeAnalysis.ruleset | 31 + Directory.Build.props | Bin 0 -> 1410 bytes .../CodeAnalysis.ruleset | 16 + JsonDeepEqual.SampleConsole/Program.cs | 135 ++++ .../Two.JsonDeepEqual.SampleConsole.csproj | 25 + JsonDeepEqual.Tests/CodeAnalysis.ruleset | 17 + .../JsonDeepEqualAssertTest.cs | 720 ++++++++++++++++++ JsonDeepEqual.Tests/JsonDeepEqualDiffTest.cs | 638 ++++++++++++++++ JsonDeepEqual.Tests/JsonDiffNodeTest.cs | 246 ++++++ JsonDeepEqual.Tests/JsonDiffTest.cs | 51 ++ JsonDeepEqual.Tests/TestEntities/Company.cs | 88 +++ .../TestEntities/TestClasses.cs | 200 +++++ .../Two.JsonDeepEqual.Tests.csproj | 30 + JsonDeepEqual.sln | 56 ++ .../Exceptions/JsonEqualException.cs | 123 +++ .../Exceptions/JsonNotEqualException.cs | 32 + JsonDeepEqual/JsonAssert.cs | 209 +++++ JsonDeepEqual/JsonDeepEqualAssert.cs | 137 ++++ JsonDeepEqual/JsonDeepEqualDiff.cs | 42 + JsonDeepEqual/JsonDeepEqualDiffOptions.cs | 318 ++++++++ JsonDeepEqual/JsonDiff.cs | 285 +++++++ JsonDeepEqual/JsonDiffNode.cs | 222 ++++++ JsonDeepEqual/JsonDiffOptions.cs | 149 ++++ JsonDeepEqual/Two.JsonDeepEqual.csproj | 28 + JsonDeepEqual/Utilities/GlobConvert.cs | 74 ++ LICENSE | 21 + README.md | 191 +++++ UNLICENSE | 24 + appveyor.yml | 13 + stylecop.json | 14 + 33 files changed, 4499 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CodeAnalysis.ruleset create mode 100644 Directory.Build.props create mode 100644 JsonDeepEqual.SampleConsole/CodeAnalysis.ruleset create mode 100644 JsonDeepEqual.SampleConsole/Program.cs create mode 100644 JsonDeepEqual.SampleConsole/Two.JsonDeepEqual.SampleConsole.csproj create mode 100644 JsonDeepEqual.Tests/CodeAnalysis.ruleset create mode 100644 JsonDeepEqual.Tests/JsonDeepEqualAssertTest.cs create mode 100644 JsonDeepEqual.Tests/JsonDeepEqualDiffTest.cs create mode 100644 JsonDeepEqual.Tests/JsonDiffNodeTest.cs create mode 100644 JsonDeepEqual.Tests/JsonDiffTest.cs create mode 100644 JsonDeepEqual.Tests/TestEntities/Company.cs create mode 100644 JsonDeepEqual.Tests/TestEntities/TestClasses.cs create mode 100644 JsonDeepEqual.Tests/Two.JsonDeepEqual.Tests.csproj create mode 100644 JsonDeepEqual.sln create mode 100644 JsonDeepEqual/Exceptions/JsonEqualException.cs create mode 100644 JsonDeepEqual/Exceptions/JsonNotEqualException.cs create mode 100644 JsonDeepEqual/JsonAssert.cs create mode 100644 JsonDeepEqual/JsonDeepEqualAssert.cs create mode 100644 JsonDeepEqual/JsonDeepEqualDiff.cs create mode 100644 JsonDeepEqual/JsonDeepEqualDiffOptions.cs create mode 100644 JsonDeepEqual/JsonDiff.cs create mode 100644 JsonDeepEqual/JsonDiffNode.cs create mode 100644 JsonDeepEqual/JsonDiffOptions.cs create mode 100644 JsonDeepEqual/Two.JsonDeepEqual.csproj create mode 100644 JsonDeepEqual/Utilities/GlobConvert.cs create mode 100644 LICENSE create mode 100644 README.md create mode 100644 UNLICENSE create mode 100644 appveyor.yml create mode 100644 stylecop.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5af9588 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = false + +[*] +indent_style = space +indent_size = 4 +end_of_line = crlf + +[*.{csproj,props,json,ruleset}] +indent_size = 2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ac5bf39 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* -text +*.cs diff=csharp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..353ae84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,353 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ \ No newline at end of file diff --git a/CodeAnalysis.ruleset b/CodeAnalysis.ruleset new file mode 100644 index 0000000..2180d5d --- /dev/null +++ b/CodeAnalysis.ruleset @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000000000000000000000000000000000000..04af7a484c2ab3c851888e0ed11f17952a1a58f7 GIT binary patch literal 1410 zcmbW1Pfx-?5XI+g;&&j#1i1iL6CpuSLZZe9(KG)<6bfnK&@Zq4X4a*&W{Hw!({|_0 zo40SK{rbF=M~P)6?=qFKY@{Wpav~)zA6XNLiKNmsQ+;|te{5ZeWCm}Wb;FL%Ow4oMx&P$lD?XSr3#yHN zI-))FZ+ep72+yEVH^t#u&9Gr)$=r(Q!hAO&O({#o`lL=q@=9!m));MmimD)*Tos$q z5z!gXWxVlKvq^W+0ew`hE%A#zPE!7?yaoBDdHD{ZbrH5#`zb;+SgA@I6jD zrDFIW(8jlY=XCD<6en=?aBs}s7St5C%12JW{vA?y^qW}WVvT=SV!bIk?p(j4W08E| ebvRS~$Z-DDF2ByqA39mqWn~HZlsTUN(|!X#LhD8V literal 0 HcmV?d00001 diff --git a/JsonDeepEqual.SampleConsole/CodeAnalysis.ruleset b/JsonDeepEqual.SampleConsole/CodeAnalysis.ruleset new file mode 100644 index 0000000..b7decab --- /dev/null +++ b/JsonDeepEqual.SampleConsole/CodeAnalysis.ruleset @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/JsonDeepEqual.SampleConsole/Program.cs b/JsonDeepEqual.SampleConsole/Program.cs new file mode 100644 index 0000000..bfd0670 --- /dev/null +++ b/JsonDeepEqual.SampleConsole/Program.cs @@ -0,0 +1,135 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using System; +using System.Collections.Generic; +using System.Linq; +using Two.JsonDeepEqual; +using Two.JsonDeepEqual.Exceptions; + +#pragma warning disable CA1801 // Remove unused parameter +#pragma warning disable CA1307 // Specify StringComparison + +namespace SampleConsole +{ + public static class Program + { + public static void Main(string[] args) + { + Sample_Basic(); + Sample_WithOptions(); + Sample_Json(); + Sample_WithAllOptions(); + Diff_Basic(); + Diff_Json(); + } + + public static void Sample_Basic() + { + var expected = new + { + Message = "Hello!", + Child = new { Id = 1, Values = new[] { 1, 2, 3 } }, + }; + var actual = new + { + Message = "Hello, World!", + Child = new { Id = 2, Values = new[] { 1, 4, 3 } }, + }; + try + { + JsonDeepEqualAssert.Equal(expected, actual); + JsonDeepEqualAssert.AreEqual(expected, actual); + } + catch (JsonEqualException ex) + { + Console.WriteLine(ex.Message); + Console.WriteLine(); + } + + JsonDeepEqualAssert.NotEqual(expected, actual); + JsonDeepEqualAssert.AreNotEqual(expected, actual); + } + + public static void Sample_WithOptions() + { + var expected = new + { + Id = 1, + Message = "Hello!", + Child = new { Id = 10, Values = new[] { 1, 2, 3 } }, + Created = new DateTime(2002, 2, 2, 12, 22, 23), + }; + var actual = new + { + Id = 2, + Message = "Hello, World!", + Child = new { Id = 11, Values = new[] { 1, 4, 3 } }, + Created = new DateTime(2002, 2, 2, 12, 22, 22, 999), + }; + JsonDeepEqualAssert.Equal(expected, actual, new JsonDeepEqualDiffOptions + { + ExcludePropertyNames = new[] { "Id", "Mess*" }, + ExcludePropertyPaths = new[] { "**/Values/*" }, + IgnoreArrayElementOrder = true, + DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFK", + DateTimeConverter = (DateTime dt) => new System.Data.SqlTypes.SqlDateTime(dt).Value, + }); + } + + public static void Sample_Json() + { + var expectedJson = @"{ ""a"":1 }"; + var actualJson = @"{ ""a"":2 }"; + try + { + JsonAssert.Equal(expectedJson, actualJson); + } + catch (JsonEqualException ex) + { + Console.WriteLine(ex.Message); + Console.WriteLine(); + } + } + + public static void Sample_WithAllOptions() + { + var expected = 1; + var actual = 1; + JsonDeepEqualAssert.Equal(expected, actual, new JsonDeepEqualDiffOptions + { + ExcludePropertyNames = new[] { "Id", "*DateTime" }, + PropertyFilter = (IEnumerable properties) => properties.Where(p => p.PropertyName != "Id" && p.PropertyType != typeof(DateTime)), + NullValueHandling = NullValueHandling.Include, + DefaultValueHandling = DefaultValueHandling.Include, + ReferenceLoopHandling = ReferenceLoopHandling.Error, + DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFK", + DateTimeConverter = (DateTime dt) => new System.Data.SqlTypes.SqlDateTime(dt).Value, + + ExcludePropertyPaths = new[] { "**System/*" }, + PropertyPathFilter = (IEnumerable paths) => paths.Where(p => !p.Contains("System")), + IgnoreArrayElementOrder = true, + IgnoreCase = true, + IgnoreLineEndingDifferences = true, + IgnoreWhiteSpaceDifferences = true, + }); + } + + public static void Diff_Basic() + { + var expected = new { Message = "Hello!" }; + var actual = new { Message = "Hello, World!" }; + + IEnumerable differences = JsonDeepEqualDiff.EnumerateDifferences(expected, actual); + Console.WriteLine(string.Join(Environment.NewLine, differences.Take(10))); + } + + public static void Diff_Json() + { + var expectedJson = @"{ ""a"":1 }"; + var actualJson = @"{ ""a"":2 }"; + + IEnumerable differences = JsonDiff.EnumerateDifferences(expectedJson, actualJson); + Console.WriteLine(string.Join(Environment.NewLine, differences.Take(10))); + } + } +} diff --git a/JsonDeepEqual.SampleConsole/Two.JsonDeepEqual.SampleConsole.csproj b/JsonDeepEqual.SampleConsole/Two.JsonDeepEqual.SampleConsole.csproj new file mode 100644 index 0000000..151707a --- /dev/null +++ b/JsonDeepEqual.SampleConsole/Two.JsonDeepEqual.SampleConsole.csproj @@ -0,0 +1,25 @@ + + + + Exe + netcoreapp3.0 + CodeAnalysis.ruleset + 1701;1702;1591 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/JsonDeepEqual.Tests/CodeAnalysis.ruleset b/JsonDeepEqual.Tests/CodeAnalysis.ruleset new file mode 100644 index 0000000..f234a6d --- /dev/null +++ b/JsonDeepEqual.Tests/CodeAnalysis.ruleset @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/JsonDeepEqual.Tests/JsonDeepEqualAssertTest.cs b/JsonDeepEqual.Tests/JsonDeepEqualAssertTest.cs new file mode 100644 index 0000000..fc4a9b1 --- /dev/null +++ b/JsonDeepEqual.Tests/JsonDeepEqualAssertTest.cs @@ -0,0 +1,720 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Two.JsonDeepEqual.Exceptions; +using Xunit; + +namespace Two.JsonDeepEqual +{ + public class JsonDeepEqualAssertTest + { + [Theory] + [InlineData(2, 2, true)] + [InlineData(2, 1, false)] + [InlineData(2, 2f, false)] + [InlineData(0f, 0f, true)] + [InlineData(0f, float.Epsilon * 2, true)] + [InlineData(0d, 0d, true)] + [InlineData(0d, double.Epsilon * 2, true)] + [InlineData("Hello world!", "Hello world!", true)] + [InlineData("Hello World!", "Hello world!", false)] + [InlineData("hello", "world", false)] + [InlineData(2, null, false)] + [InlineData("test", null, false)] + [InlineData(null, null, true)] + public void Equal_SimpleValues(object a, object b, bool expected) + { + if (expected) + { + AssertDeepEqual(a, b); + AssertDeepEqual(b, a); + } + else + { + AssertNotDeepEqual(a, b); + AssertNotDeepEqual(b, a); + } + } + + [Fact] + public void Equal_ByteArrayWithSelf() + { + var a = new byte[] { 1, 171, 128, 3 }; + AssertDeepEqual(a, a); + } + + [Fact] + public void Equal_ByteArrays() + { + var a = new byte[] { 1, 171, 128, 3 }; + var b = new byte[] { 1, 171, 128, 3 }; + AssertDeepEqual(a, b); + } + + [Fact] + public void NotEqual_ByteArrays() + { + var a = new byte[] { 1, 171, 128, 3 }; + var b = new byte[] { 1, 171, 128, 2 }; + AssertNotDeepEqual(a, b); + } + + [Fact] + public void Equal_FloatsAlmostEqual() + { + var a = 0f; + var b = float.Epsilon / 2; + AssertDeepEqual(a, b); + } + + [Fact] + public void Equal_DoublesAlmostEqual() + { + var a = 0d; + var b = double.Epsilon / 2; + AssertDeepEqual(a, b); + } + + [Fact] + public void Equal_SameEmptyObjects() + { + var obj = new object(); + JsonDeepEqualAssert.Equal(obj, obj); + } + + [Fact] + public void Equal_EmptyObjects() + { + JsonDeepEqualAssert.Equal(new object(), new object()); + } + + [Fact] + public void Equal_SameString() + { + JsonDeepEqualAssert.Equal("2", "2"); + } + + [Fact] + public void NotEqual_DifferentStrings() + { + JsonDeepEqualAssert.NotEqual("1", "2"); + } + + #region Messages + + [Fact] + public void Equal_DifferentStrings_ShouldThrowExceptionWithExpectedMessage() + { + var expected = "Test hello"; + var actual = "Test world"; + var actualException = Assert.Throws(() => JsonDeepEqualAssert.Equal(expected, actual)); + + var expectedMessage = @"JsonDeepEqualAssert.Equal() Failure: 1 difference + ↓ (pos 6) + Expected: ""Test hello"" + Actual: ""Test world"" + ↑ (pos 6)"; + Assert.Equal(expectedMessage, actualException.Message, ignoreLineEndingDifferences: true); + } + + [Fact] + public void Equal_DifferentAnonymousObjects_ShouldThrowExceptionWithExpectedMessage() + { + var expected = new { id = 1, message = "hello" }; + var actual = new { id = 2, message = "world" }; + var actualException = Assert.Throws(() => JsonDeepEqualAssert.Equal(expected, actual)); + + var expectedMessage = @"JsonDeepEqualAssert.Equal() Failure: 2 differences +/id: + Expected: 1 + Actual: 2 +/message: + ↓ (pos 1) + Expected: ""hello"" + Actual: ""world"" + ↑ (pos 1)"; + Assert.Equal(expectedMessage, actualException.Message, ignoreLineEndingDifferences: true); + } + + [Fact] + public void Equal_DifferentCustomObjects_ShouldThrowExceptionWithExpectedMessage() + { + var expected = new Company { Id = 1, Name = "hello" }; + var actual = new Company { Id = 2, Name = "world" }; + var actualException = Assert.Throws(() => JsonDeepEqualAssert.Equal(expected, actual)); + + var expectedMessage = @"JsonDeepEqualAssert.Equal() Failure: 2 differences +/Id: + Expected: 1 + Actual: 2 +/Name: + ↓ (pos 1) + Expected: ""hello"" + Actual: ""world"" + ↑ (pos 1)"; + Assert.Equal(expectedMessage, actualException.Message, ignoreLineEndingDifferences: true); + } + + [Fact] + public void Equal_DifferentIntLists_ShouldThrowExceptionWithExpectedMessage() + { + var expected = new[] { 0, 1, 2 }; + var actual = new[] { 1, 2 }; + var actualException = Assert.Throws(() => JsonDeepEqualAssert.Equal(expected, actual)); + + var expectedMessage = @"JsonDeepEqualAssert.Equal() Failure: 3 differences +/0: + Expected: 0 + Actual: 1 +/1: + Expected: 1 + Actual: 2 +/2: + Expected: 2 + Actual: null"; + Assert.Equal(expectedMessage, actualException.Message, ignoreLineEndingDifferences: true); + } + + [Fact] + public void Equal_DifferentObjects_LongValues_ShouldThrowExceptionWithExpectedMessage() + { + var obj1 = new Person + { + Id = 1, + FullName = string.Join(string.Empty, Enumerable.Range(0, 512)), + }; + var obj2 = new Person + { + Id = 2, + FullName = string.Join(string.Empty, Enumerable.Range(0, 256)) + string.Join(string.Empty, Enumerable.Range(1, 256)), + }; + var actualException = Assert.Throws(() => JsonDeepEqualAssert.Equal(obj1, obj2)); + + var expectedMessage = @"JsonDeepEqualAssert.Equal() Failure: 2 differences +/Id: + Expected: 1 + Actual: 2 +/FullName: + ↓ (pos 659) + Expected: …4925025125225325425525625725825926026126226326426526626726826… + Actual: …4925025125225325425512345678910111213141516171819202122232425… + ↑ (pos 659)"; + Assert.Equal(expectedMessage, actualException.Message, ignoreLineEndingDifferences: true); + } + + [Fact] + public void Equal_LotsOfDifferences_ShouldThrowExceptionWithExpectedMessage() + { + var obj1 = new ListTestClass + { + List = Enumerable.Range(0, 512).ToList(), + }; + var obj2 = new ListTestClass + { + List = Enumerable.Range(1, 512).ToList(), + }; + var actualException = Assert.Throws(() => JsonDeepEqualAssert.Equal(obj1, obj2)); + + var expectedStartMessage = @"JsonDeepEqualAssert.Equal() Failure: 20+ differences +/List/0: + Expected: 0 + Actual: 1 +/List/1: + Expected: 1 + Actual: 2"; + Assert.StartsWith( + expectedStartMessage.Replace("\r", string.Empty, StringComparison.Ordinal), + actualException.Message.Replace("\r", string.Empty, StringComparison.Ordinal), + StringComparison.Ordinal + ); + } + + [Fact] + public void NotEqual_SameString_ShouldThrowExceptionWithExpectedMessage() + { + var expected = "hello"; + var actual = "hello"; + var actualException = Assert.Throws(() => JsonDeepEqualAssert.NotEqual(expected, actual)); + + var expectedMessage = "JsonDeepEqualAssert.NotEqual() Failure"; + Assert.Equal(expectedMessage, actualException.Message, ignoreLineEndingDifferences: true); + } + + #endregion + + [Fact] + public void Equal_EqualAddresses() + { + var a = CreateSampleAddress(); + var b = CreateSampleAddress(); + AssertDeepEqual(a, b); + } + + [Fact] + public void Equal_DifferentAddresses_ShouldThrowException() + { + var a = new Address + { + Id = 1, + Lines = new[] { "123 Fake ST", "Arlington, VA 22222" }, + }; + var b = new Address + { + Id = 1, + Lines = new[] { "321 Fake ST", "Arlington, VA 22222" }, + }; + AssertNotDeepEqual(a, b); + AssertDeepEqual(a, b, new JsonDeepEqualDiffOptions + { + ExcludePropertyNames = new[] { nameof(Address.Lines) }, + }); + + var actualException = Assert.Throws(() => JsonDeepEqualAssert.Equal(a, b)); + Assert.Single(actualException.Differences); + Assert.Equal("/Lines/0", actualException.Differences.ElementAt(0).Path); + Assert.Equal("\"123 Fake ST\"", actualException.Differences.ElementAt(0).ExpectedValueDisplay); + Assert.Equal("\"321 Fake ST\"", actualException.Differences.ElementAt(0).ActualValueDisplay); + } + + [Fact] + public void Equal_EqualCompanies() + { + var a = CreateSampleCompany(); + var b = CreateSampleCompany(); + AssertDeepEqual(a, b); + } + + [Fact] + public void Equal_DifferentCompanies_ShouldThrowException() + { + var a = CreateSampleCompany(); + var b = CreateSampleCompany(); + b.Employees.First().FullName = "Robert Plant"; + + Assert.NotEqual(a, b); + AssertDeepEqual(a, b, new JsonDeepEqualDiffOptions + { + ExcludePropertyPaths = new[] { "/Employees/**" }, + }); + AssertDeepEqual(a, b, new JsonDeepEqualDiffOptions + { + ExcludePropertyPaths = new[] { "/Employees/*/FullName" }, + }); + AssertDeepEqual(a, b, new JsonDeepEqualDiffOptions + { + ExcludePropertyPaths = new[] { "**Employees/*/FullName" }, + }); + AssertDeepEqual(a, b, new JsonDeepEqualDiffOptions + { + ExcludePropertyNames = new[] { "FullName" }, + }); + AssertDeepEqual(a, b, new JsonDeepEqualDiffOptions + { + ExcludePropertyNames = new[] { "*Name*" }, + }); + AssertDeepEqual(a, b, new JsonDeepEqualDiffOptions + { + ExcludePropertyPaths = new[] { "Employees/0/*Name*" }, + }); + + var actualException = Assert.Throws(() => JsonDeepEqualAssert.Equal(a, b)); + Assert.Single(actualException.Differences); + Assert.Equal("/Employees/0/FullName", actualException.Differences.ElementAt(0).Path); + Assert.Equal("\"Robert Paulson\"", actualException.Differences.ElementAt(0).ExpectedValueDisplay); + Assert.Equal("\"Robert Plant\"", actualException.Differences.ElementAt(0).ActualValueDisplay); + } + + [Fact] + public void Equal_DifferentCompaniesWithPrivateGetters() + { + var a = new CompanyPrivateGetters + { + Id = 1, + Name = "The Company", + }; + var b = new CompanyPrivateGetters + { + Id = 1, + Name = "A Company", + }; + AssertDeepEqual(a, b); + } + + [Fact] + public void Equal_EqualPeople_Cycle() + { + var a = CreateSamplePerson_OwnFatherAndMotherSomehow(); + var b = CreateSamplePerson_OwnFatherAndMotherSomehow(); + AssertDeepEqual(a, b); + } + + [Fact] + public void NotEqual_DifferentPeople_PartialCycle() + { + var a = CreateSamplePerson_OwnFatherAndMotherSomehow(); + var b = CreateSamplePerson_OwnFatherAndMotherSomehow(); + b.Mother = a; + AssertNotDeepEqual(a, b); + } + + [Fact] + public void Equal_EqualPeople_OwnGrandpa() + { + var a = CreateSamplePerson_OwnGrandpa(); + var b = CreateSamplePerson_OwnGrandpa(); + AssertDeepEqual(a, b); + } + + [Fact] + public void NotEqual_DifferentPeople_OwnGrandpa() + { + var a = CreateSamplePerson_OwnGrandpa(); + var b = CreateSamplePerson_OwnGrandpa(); + b.Father!.FullName = "Bob"; + + AssertNotDeepEqual(a, b); + } + + [Fact] + public void Equal_EmptyListTestObjectsWithSameType() + { + var obj1 = new ListTestClass(); + var obj2 = new ListTestClass(); + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_ListTestObjectSamples() + { + var obj1 = new ListTestClass() + { + Enumerable = new[] { "test" }, + ReadOnlyCollection = new[] { "test2", "test22" }, + Collection = new[] { "test3" }, + Array = new[] { "test4" }, + IList = new[] { "test5" }, + List = new List { "test6" }, + }; + var obj2 = new ListTestClass() + { + Enumerable = new[] { "test" }, + ReadOnlyCollection = new[] { "test2", "test22" }, + Collection = new[] { "test3" }, + Array = new[] { "test4" }, + IList = new[] { "test5" }, + List = new List { "test6" }, + }; + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_ListTestObjectsSamples_DifferentListTypes() + { + var obj1 = new ListTestClass() + { + Enumerable = new[] { "test" }, + ReadOnlyCollection = new[] { "test2", "test22" }, + Collection = new[] { "test3" }, + Array = new[] { "test4" }, + IList = new[] { "test5" }, + List = new List { "test6" }, + }; + var obj2 = new ListTestClass() + { + Enumerable = new List { "test" }, + ReadOnlyCollection = new List { "test2", "test22" }, + Collection = new List { "test3" }, + Array = new[] { "test4" }, + IList = new List { "test5" }, + List = new List { "test6" }, + }; + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void NotEqual_DifferentListTestObjectsSamples() + { + var obj1 = new ListTestClass() + { + Enumerable = new[] { "test" }, + ReadOnlyCollection = new[] { "test2", "test22" }, + Collection = new[] { "test3" }, + Array = new[] { "test4" }, + IList = new[] { "test5" }, + List = new List { "test6" }, + }; + var obj2 = new ListTestClass() + { + Enumerable = new[] { "TEST" }, + ReadOnlyCollection = new[] { "TEST2", "TEST22" }, + Collection = new[] { "TEST3" }, + Array = new[] { "TEST4" }, + IList = new[] { "TEST5" }, + List = new List { "TEST6" }, + }; + AssertNotDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_EmptyListTestObjectsWithDifferentTypes() + { + var obj1 = new ListTestClass(); + var obj2 = new ListTestClass(); + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_EmptyListTestObjectsWithDifferentCompatibleTypes() + { + var obj1 = new ListTestClass(); + var obj2 = new ListTestClass(); + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_EmptyDictionaryTestObjectsWithSameType() + { + var obj1 = new DictionaryTestClass(); + var obj2 = new DictionaryTestClass(); + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_EmptyDictionariesWithSameType() + { + var obj1 = new DictionaryTestClass + { + Dictionary = new Dictionary(), + DictionaryOfEnumerables = new Dictionary>(), + DictionaryOfCollections = new Dictionary>(), + }; + var obj2 = new DictionaryTestClass() + { + Dictionary = new Dictionary(), + DictionaryOfEnumerables = new Dictionary>(), + DictionaryOfCollections = new Dictionary>(), + }; + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_DictionariesWithSameValuesButDifferentListTypes_() + { + var person = new Person { FullName = "Robert Paulson" }; + var obj1 = new DictionaryTestClass + { + Dictionary = new Dictionary() { [1] = person }, + DictionaryOfEnumerables = new Dictionary>() { [1] = new Person[] { person } }, + DictionaryOfCollections = new Dictionary>() { [1] = new Person[] { person } }, + }; + var obj2 = new DictionaryTestClass() + { + Dictionary = new Dictionary() { [1] = person }, + DictionaryOfEnumerables = new Dictionary>() { [1] = new List { person } }, + DictionaryOfCollections = new Dictionary>() { [1] = new List { person } }, + }; + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_DictionaryTestObjectsWithEqualValuesButDifferentListTypes() + { + var person1 = new Person { FullName = "Robert Paulson" }; + var person2 = new Person { FullName = "Robert Paulson" }; + + var obj1 = new DictionaryTestClass + { + Dictionary = new Dictionary() { [1] = person1 }, + DictionaryOfEnumerables = new Dictionary>() { [1] = new Person[] { person1 } }, + DictionaryOfCollections = new Dictionary>() { [1] = new Person[] { person1 } }, + }; + var obj2 = new DictionaryTestClass() + { + Dictionary = new Dictionary() { [1] = person2 }, + DictionaryOfEnumerables = new Dictionary>() { [1] = new List { person2 } }, + DictionaryOfCollections = new Dictionary>() { [1] = new List { person2 } }, + }; + + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void NotEqual_ReflectionPropertiesObjectSample() + { + var obj1 = new ReflectionValuesTestClass() + { + Type = typeof(string), + PropertyInfo = typeof(string).GetRuntimeProperty(nameof(string.Length)), + }; + var obj2 = new ReflectionValuesTestClass() + { + Type = typeof(ReflectionValuesTestClass), + PropertyInfo = typeof(ReflectionValuesTestClass).GetRuntimeProperty(nameof(ReflectionValuesTestClass.PropertyInfo)), + }; + AssertNotDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_EmptyArrays() + { +#pragma warning disable CA1825 // Avoid zero-length array allocations. + var obj1 = new string[0]; + var obj2 = new string[0]; +#pragma warning restore CA1825 // Avoid zero-length array allocations. + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_PrimitiveValuesObjectSample() + { + var obj1 = PrimitiveValuesTestClass.CreateSample(); + var obj2 = PrimitiveValuesTestClass.CreateSample(); + AssertDeepEqual(obj1, obj2); + } + + [Fact] + public void Equal_Arrays_IgnoreOptions() + { + var expected = new[] { 1, 2, 3 }; + var actual = new[] { 3, 2, 1 }; + AssertNotDeepEqual(expected, actual); + AssertDeepEqual(expected, actual, new JsonDeepEqualDiffOptions { IgnoreArrayElementOrder = true }); + } + + [Fact] + public void Equal_Strings_IgnoreOptions() + { + var expected = "Hell o\nWorld"; + var actual = "hell o\r\nworld"; + AssertNotDeepEqual(expected, actual); + AssertDeepEqual(expected, actual, new JsonDeepEqualDiffOptions { IgnoreCase = true, IgnoreWhiteSpaceDifferences = true, IgnoreLineEndingDifferences = true }); + } + + #region Helpers + + private void AssertDeepEqual(object? expected, object? actual) + { + JsonDeepEqualAssert.Equal(expected, actual); + JsonDeepEqualAssert.AreEqual(expected, actual); + Assert.Throws(() => JsonDeepEqualAssert.NotEqual(expected, actual)); + Assert.Throws(() => JsonDeepEqualAssert.AreNotEqual(expected, actual)); + AssertDeepEqual(expected, actual, null); + } + + private static void AssertDeepEqual(object? expected, object? actual, JsonDeepEqualDiffOptions? options) + { + JsonDeepEqualAssert.Equal(expected, actual, options); + JsonDeepEqualAssert.AreEqual(expected, actual, options); + Assert.Throws(() => JsonDeepEqualAssert.NotEqual(expected, actual, options)); + Assert.Throws(() => JsonDeepEqualAssert.AreNotEqual(expected, actual, options)); + } + + private void AssertNotDeepEqual(object? expected, object? actual) + { + JsonDeepEqualAssert.NotEqual(expected, actual); + JsonDeepEqualAssert.AreNotEqual(expected, actual); + Assert.Throws(() => JsonDeepEqualAssert.Equal(expected, actual)); + Assert.Throws(() => JsonDeepEqualAssert.AreEqual(expected, actual)); + AssertNotDeepEqual(expected, actual, null); + } + + private static void AssertNotDeepEqual(object? expected, object? actual, JsonDeepEqualDiffOptions? options) + { + JsonDeepEqualAssert.NotEqual(expected, actual, options); + JsonDeepEqualAssert.AreNotEqual(expected, actual, options); + Assert.Throws(() => JsonDeepEqualAssert.Equal(expected, actual, options)); + Assert.Throws(() => JsonDeepEqualAssert.AreEqual(expected, actual, options)); + } + + #endregion + + #region Samples + + private Address CreateSampleAddress() + { + var address = new Address + { + Id = 1, + Lines = new[] { "123 Fake ST", "Arlington, VA 22222" }, + }; + return address; + } + + private Company CreateSampleCompany() + { + var company = new Company + { + Id = 1, + Name = "The Company", + Employees = new[] + { + new Employee + { + Id = 2, + FullName = "Robert Paulson", + Addresses = new[] + { + new Address { Id = 3, AddressType = AddressType.Home, Lines = new[] { "123 Fake ST", "Arlington, VA 22222" } }, + new Address { Id = 4, AddressType = AddressType.Work, Lines = new[] { "2 Company BLVD", "Arlington, VA 22222" } }, + }, + Phones = new[] + { + new Phone { Id = 5, PhoneType = PhoneType.Cell, Number = "555-555-5555", }, + }, + }, + new Employee + { + Id = 6, + FullName = "Jenny Heath", + Phones = new[] + { + new Phone { Id = 7, PhoneType = PhoneType.Home, Number = "555-867-5309", }, + }, + }, + }, + }; + return company; + } + + private Person CreateSamplePerson_OwnFatherAndMotherSomehow() + { + var person = new Person { FullName = "a" }; + person.Father = person; + person.Mother = person; + person.Children = new[] { person }; + return person; + } + + private Person CreateSamplePerson_OwnGrandpa() + { + var protagonistFather = new Person { FullName = "Protagonist's Father" }; + var protagonistMother = new Person { FullName = "Protagonist's Mother" }; + var protagonist = new Person { FullName = "Protagonist", Father = protagonistFather, Mother = protagonistMother }; + + var widow = new Person { FullName = "Widow" }; + var deadMan = new Person { FullName = "Dead Man " }; + var widowDaughter = new Person { FullName = "Widow's Daughter", Father = deadMan, Mother = widow }; + + var protagonistBaby = new Person { FullName = "Protagonist's Baby", Father = protagonist, Mother = widow }; + var protagonistStepBrotherAndStepGrandchild = new Person { FullName = "Grandchild", Father = protagonistFather, Mother = widowDaughter }; + + protagonist.Spouses = new[] { widow }; + widow.Spouses = new[] { deadMan, protagonist }; + deadMan.Spouses = new[] { widow }; + protagonistMother.Spouses = new[] { protagonistFather }; + protagonistFather.Spouses = new[] { protagonistMother, widowDaughter }; + widowDaughter.Spouses = new[] { protagonistFather }; + + protagonist.Children = new[] { protagonistBaby }; + protagonistFather.Children = new[] { protagonist, protagonistStepBrotherAndStepGrandchild }; + protagonistMother.Children = new[] { protagonist }; + widow.Children = new[] { widowDaughter, protagonistBaby }; + widowDaughter.Children = new[] { protagonistStepBrotherAndStepGrandchild }; + + return protagonist; + } + + #endregion + } +} diff --git a/JsonDeepEqual.Tests/JsonDeepEqualDiffTest.cs b/JsonDeepEqual.Tests/JsonDeepEqualDiffTest.cs new file mode 100644 index 0000000..f93e31e --- /dev/null +++ b/JsonDeepEqual.Tests/JsonDeepEqualDiffTest.cs @@ -0,0 +1,638 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Linq; +using Xunit; + +namespace Two.JsonDeepEqual +{ + public class JsonDeepEqualDiffTest + { + [Theory] + [InlineData(2, 2, 0)] + [InlineData(1, 2, 1)] + [InlineData(2.0f, 2.0f, 0)] + [InlineData(1.0f, 2.0f, 1)] + [InlineData("hello", "hello", 0)] + [InlineData("hello", "world", 1)] + public void EnumerateDifferences_SimpleValue(object a, object b, int expectedDifferenceCount) + { + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Equal(expectedDifferenceCount, differences.Count); + } + + [Fact] + public void EnumerateDifferences_PrimitiveArrays_ShouldBeEqual() + { + var a = new int[] { 1, 2, 3 }; + var b = new int[] { 1, 2, 3 }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Empty(differences); + } + + [Fact] + public void EnumerateDifferences_BinaryArrays_ShouldBeEqual() + { + var a = new byte[] { 1, 2, 3 }; + var b = new byte[] { 1, 2, 3 }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Empty(differences); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnumerateDifferences_PrimitiveArrays_SameLengtWithNoOverlap_ShouldHaveDifferences(bool ignoreOrder) + { + var a = new int[] { 1, 2, 3 }; + var b = new int[] { 4, 5, 6 }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b, new JsonDeepEqualDiffOptions { IgnoreArrayElementOrder = ignoreOrder }).ToList(); + if (!ignoreOrder) + { + Assert.Equal(3, differences.Count); + + Assert.Equal("/0", differences[0].Path); + Assert.Equal(1, differences[0].ExpectedValue.ToObject()); + Assert.Equal(4, differences[0].ActualValue.ToObject()); + + Assert.Equal("/1", differences[1].Path); + Assert.Equal(2, differences[1].ExpectedValue.ToObject()); + Assert.Equal(5, differences[1].ActualValue.ToObject()); + + Assert.Equal("/2", differences[2].Path); + Assert.Equal(3, differences[2].ExpectedValue.ToObject()); + Assert.Equal(6, differences[2].ActualValue.ToObject()); + } + else + { + Assert.Single(differences); + + Assert.Equal("/*", differences[0].Path); + Assert.Equal(a, differences[0].ExpectedValue.ToObject()); + Assert.Equal(b, differences[0].ActualValue.ToObject()); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnumerateDifferences_PrimitiveArrays_MissingActualElementWithNoOverlap_ShouldHaveDifferences(bool ignoreOrder) + { + var a = new int[] { 1, 2, 3 }; + var b = new int[] { 4, 5 }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b, new JsonDeepEqualDiffOptions { IgnoreArrayElementOrder = ignoreOrder }).ToList(); + if (!ignoreOrder) + { + Assert.Equal(3, differences.Count); + + Assert.Equal("/0", differences[0].Path); + Assert.Equal(1, differences[0].ExpectedValue.ToObject()); + Assert.Equal(4, differences[0].ActualValue.ToObject()); + + Assert.Equal("/1", differences[1].Path); + Assert.Equal(2, differences[1].ExpectedValue.ToObject()); + Assert.Equal(5, differences[1].ActualValue.ToObject()); + + Assert.Equal("/2", differences[2].Path); + Assert.Equal(3, differences[2].ExpectedValue.ToObject()); + Assert.Null(differences[2].ActualValue.ToObject()); + } + else + { + Assert.Equal(2, differences.Count); + + Assert.Equal("/*", differences[0].Path); + Assert.Equal(a, differences[0].ExpectedValue.ToObject()); + Assert.Equal(b, differences[0].ActualValue.ToObject()); + + Assert.Equal("/length", differences[1].Path); + Assert.Equal(3, differences[1].ExpectedValue.ToObject()); + Assert.Equal(2, differences[1].ActualValue.ToObject()); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnumerateDifferences_PrimitiveArrays_MissingExpectedElementWithNoOverlap_ShouldHaveDifferences(bool ignoreOrder) + { + var a = new int[] { 1, 2 }; + var b = new int[] { 3, 4, 5 }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b, new JsonDeepEqualDiffOptions { IgnoreArrayElementOrder = ignoreOrder }).ToList(); + if (!ignoreOrder) + { + Assert.Equal(3, differences.Count); + + Assert.Equal("/0", differences[0].Path); + Assert.Equal(1, differences[0].ExpectedValue.ToObject()); + Assert.Equal(3, differences[0].ActualValue.ToObject()); + + Assert.Equal("/1", differences[1].Path); + Assert.Equal(2, differences[1].ExpectedValue.ToObject()); + Assert.Equal(4, differences[1].ActualValue.ToObject()); + + Assert.Equal("/2", differences[2].Path); + Assert.Null(differences[2].ExpectedValue.ToObject()); + Assert.Equal(5, differences[2].ActualValue.ToObject()); + } + else + { + Assert.Equal(2, differences.Count); + + Assert.Equal("/*", differences[0].Path); + Assert.Equal(a, differences[0].ExpectedValue.ToObject()); + Assert.Equal(b, differences[0].ActualValue.ToObject()); + + Assert.Equal("/length", differences[1].Path); + Assert.Equal(2, differences[1].ExpectedValue.ToObject()); + Assert.Equal(3, differences[1].ActualValue.ToObject()); + } + } + + [Fact] + public void EnumerateDifferences_PlainObjects_ShouldBeEqual() + { + var a = new object(); + var b = new object(); + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Empty(differences); + } + + [Fact] + public void EnumerateDifferences_TestObject1_SameObject() + { + var a = new TestClass1 + { + Id = 1, + Name = "Test", + BinaryData = new byte[] { 1, 2, 3 }, + ChildArray = new[] + { + new TestClass1Child { ChildId = 1, }, + new TestClass1Child { ChildId = 2, }, + }, + ChildCollection = new[] + { + new TestClass1Child { ChildId = 3, }, + new TestClass1Child { ChildId = 4, }, + }, + }; + + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, a).ToList(); + Assert.Empty(differences); + } + + [Fact] + public void EnumerateDifferences_TestObject1_Equal() + { + var a = new TestClass1 + { + Id = 1, + Name = "Test", + BinaryData = new byte[] { 1, 2, 3 }, + ChildArray = new[] + { + new TestClass1Child { ChildId = 1, }, + new TestClass1Child { ChildId = 2, }, + }, + ChildCollection = new[] + { + new TestClass1Child { ChildId = 3, }, + new TestClass1Child { ChildId = 4, }, + }, + }; + var b = new TestClass1 + { + Id = 1, + Name = "Test", + BinaryData = new byte[] { 1, 2, 3 }, + ChildArray = new[] + { + new TestClass1Child { ChildId = 1, }, + new TestClass1Child { ChildId = 2, }, + }, + ChildCollection = new[] + { + new TestClass1Child { ChildId = 3, }, + new TestClass1Child { ChildId = 4, }, + }, + }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Empty(differences); + } + + [Fact] + public void EnumerateDifferences_TestObject1_DifferentValues() + { + var a = new TestClass1 + { + Id = 1, + Name = "Test", + ChildArray = new[] + { + new TestClass1Child { ChildId = 1, }, + new TestClass1Child { ChildId = 2, }, + }, + ChildCollection = new[] + { + new TestClass1Child { ChildId = 3, }, + new TestClass1Child { ChildId = 4, }, + }, + }; + var b = new TestClass1 + { + Id = 1, + Name = "Test2", + ChildArray = new[] + { + new TestClass1Child { ChildId = 1, }, + new TestClass1Child { ChildId = 2, }, + }, + ChildCollection = new[] + { + new TestClass1Child { ChildId = 22, }, + new TestClass1Child { ChildId = 4, }, + }, + }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Equal(2, differences.Count); + + var diff0 = differences.ElementAt(0); + Assert.Contains("\"Test\"", diff0.ToString(), StringComparison.Ordinal); + Assert.Contains("\"Test2\"", diff0.ToString(), StringComparison.Ordinal); + + var diff1 = differences.ElementAt(1); + Assert.Contains("3", diff1.ToString(), StringComparison.Ordinal); + Assert.Contains("22", diff1.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void EnumerateDifferences_TestObject1_DifferentBinaryValues() + { + var a = new TestClass1 + { + Id = 1, + Name = "Test", + BinaryData = new byte[] { 1, 2, 3 }, + }; + var b = new TestClass1 + { + Id = 1, + Name = "Test", + BinaryData = new byte[] { 1, 2, 2 }, + }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Single(differences); + Assert.Equal("\"AQID\"", differences[0].ExpectedValueDisplay); + Assert.Equal("\"AQIC\"", differences[0].ActualValueDisplay); + } + + [Fact] + public void EnumerateDifferences_TestObject1_EmptyArrays() + { +#pragma warning disable CA1825 // Avoid zero-length array allocations. + var a = new TestClass1 + { + Id = 1, + Name = "Test", + ChildArray = new TestClass1Child[0], + ChildCollection = new TestClass1Child[0], + }; + var b = new TestClass1 + { + Id = 1, + Name = "Test", + ChildArray = new TestClass1Child[0], + ChildCollection = new TestClass1Child[0], + }; +#pragma warning restore CA1825 // Avoid zero-length array allocations. + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Empty(differences); + } + + [Fact] + public void EnumerateDifferences_TestObject1_MissingArrayAndDifferentCollectionLengths() + { + var a = new TestClass1 + { + Id = 1, + Name = "Test", + ChildArray = new[] + { + new TestClass1Child { ChildId = 1, }, + new TestClass1Child { ChildId = 2, }, + }, + ChildCollection = new[] + { + new TestClass1Child { ChildId = 3, }, + new TestClass1Child { ChildId = 4, }, + }, + }; + var b = new TestClass1 + { + Id = 1, + Name = "Test", + ChildCollection = new[] + { + new TestClass1Child { ChildId = 3, }, + new TestClass1Child { ChildId = 4, }, + new TestClass1Child { ChildId = 5, }, + }, + }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Equal(2, differences.Count); + + Assert.Equal("/ChildArray", differences[0].Path); + Assert.Equal(JArray.FromObject(a.ChildArray).ToString(), differences[0].ExpectedValue.ToString()); + Assert.Null(differences[0].ActualValue.ToObject()); + + Assert.Equal("/ChildCollection/2", differences[1].Path); + Assert.Null(differences[1].ExpectedValue.ToObject()); + Assert.Equal(JObject.FromObject(b.ChildCollection.ElementAt(2)).ToString(), differences[1].ActualValue.ToString()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnumerateDifferences_TestObject1_EmptyArrayAndDifferentCollectionLengths(bool includeDefaultValues) + { + var a = new TestClass1 + { + Id = 1, + Name = "Test", + ChildArray = new[] + { + new TestClass1Child { ChildId = 1, }, + new TestClass1Child { ChildId = 2, }, + }, + ChildCollection = new[] + { + new TestClass1Child { ChildId = 3, }, + new TestClass1Child { ChildId = 4, }, + }, + }; + var b = new TestClass1 + { + Id = 1, + Name = "Test", + ChildArray = new[] + { + new TestClass1Child(), + }, + ChildCollection = new[] + { + new TestClass1Child { ChildId = 3, }, + new TestClass1Child { ChildId = 4, }, + new TestClass1Child { ChildId = 5, }, + }, + }; + + var options = new JsonDeepEqualDiffOptions + { + DefaultValueHandling = includeDefaultValues ? DefaultValueHandling.Include : default(DefaultValueHandling?), + }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b, options).ToList(); + Assert.Equal(3, differences.Count); + + Assert.Equal("/ChildArray/0/ChildId", differences[0].Path); + Assert.Equal(1, differences[0].ExpectedValue.ToObject()); + if (includeDefaultValues) + { + Assert.Equal(0, differences[0].ActualValue.ToObject()); + } + else + { + Assert.Null(differences[0].ActualValue.ToObject()); + } + + Assert.Equal("/ChildArray/1", differences[1].Path); + Assert.Equal(JObject.FromObject(a.ChildArray[1]).ToString(), differences[1].ExpectedValue.ToObject()?.ToString()); + Assert.Null(differences[1].ActualValue.ToObject()); + + Assert.Equal("/ChildCollection/2", differences[2].Path); + Assert.Null(differences[2].ExpectedValue.ToObject()); + Assert.Equal(JObject.FromObject(b.ChildCollection.ElementAt(2)).ToString(), differences[2].ActualValue.ToObject()?.ToString()); + } + + [Fact] + public void EnumerateDifferences_TestObject2_DifferentClasses_NoDifferences() + { + var a = new TestClass2WithJsonAttributes + { + Id = 1, + Name = "Test", + }; + var b = new TestClass2WithoutJsonAttributes + { + Id = 1, + Name = "Test", + }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b).ToList(); + Assert.Empty(differences); + } + + [Fact] + public void EnumerateDifferences_TestObject2_DifferentClasses_IncludeJsonAttributeHandling_Differences() + { + var a = new TestClass2WithJsonAttributes + { + Id = 1, + Name = "Test", + }; + var b = new TestClass2WithoutJsonAttributes + { + Id = 1, + Name = "Test", + }; + var differences = JsonDeepEqualDiff.EnumerateDifferences(a, b, new JsonDeepEqualDiffOptions { JsonAttributeHandling = JsonAttributeHandling.Include }).ToList(); + Assert.Equal(3, differences.Count); + + Assert.Equal("/description", differences[0].Path); + Assert.Equal("Test", differences[0].ExpectedValue.ToObject()); + Assert.Null(differences[0].ActualValue.ToObject()); + + Assert.Equal("/Id", differences[1].Path); + Assert.Null(differences[1].ExpectedValue.ToObject()); + Assert.Equal(1, differences[1].ActualValue.ToObject()); + + Assert.Equal("/Name", differences[2].Path); + Assert.Null(differences[2].ExpectedValue.ToObject()); + Assert.Equal("Test", differences[2].ActualValue.ToObject()); + } + + [Fact] + public void EnumerateDifferences_DirectOneNodeCycle_DifferentRootObjects() + { + var obj1 = new SelfReferenceTestClass(); + obj1.Child = obj1; + + var obj2 = new SelfReferenceTestClass(); + obj2.Child = obj1; + + var differences = JsonDeepEqualDiff.EnumerateDifferences(obj1, obj2).ToList(); + Assert.Single(differences); + } + + [Fact] + public void EnumerateDifferences_DirectOneNodeCycle_SameRootObject() + { + var obj1 = new SelfReferenceTestClass(); + obj1.Child = obj1; + + var differences = JsonDeepEqualDiff.EnumerateDifferences(obj1, obj1).ToList(); + Assert.Empty(differences); + } + + [Fact] + public void EnumerateDifferences_Strings_IgnoreCase() + { + var expected = "Hello"; + var actual = "hello"; + Assert.NotEmpty(JsonDeepEqualDiff.EnumerateDifferences(expected, actual)); + Assert.Empty(JsonDeepEqualDiff.EnumerateDifferences(expected, actual, new JsonDeepEqualDiffOptions { IgnoreCase = true })); + } + + [Fact] + public void EnumerateDifferences_Strings_IgnoreWhiteSpaceDifferences() + { + var expected = "hello world"; + var actual = "hello world"; + Assert.NotEmpty(JsonDeepEqualDiff.EnumerateDifferences(expected, actual)); + Assert.Empty(JsonDeepEqualDiff.EnumerateDifferences(expected, actual, new JsonDeepEqualDiffOptions { IgnoreWhiteSpaceDifferences = true })); + } + + [Fact] + public void EnumerateDifferences_Strings_IgnoreLineEndingDifferences() + { + var expected = "hello\nworld"; + var actual = "hello\r\nworld"; + Assert.NotEmpty(JsonDeepEqualDiff.EnumerateDifferences(expected, actual)); + Assert.Empty(JsonDeepEqualDiff.EnumerateDifferences(expected, actual, new JsonDeepEqualDiffOptions { IgnoreLineEndingDifferences = true })); + } + + [Theory] + [InlineData(null, 123, 456, true)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ssK", 123, 456, true)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ssK", 498, 499, true)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ssK", 499, 500, true)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ssK", 500, 501, true)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ssK", 000, 999, true)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK", 000, 000, true)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK", 000, 999, false)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFK", 123, 456, false)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFK", 000, 001, false)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFK", 998, 999, false)] + [InlineData("yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFK", 000, 999, false)] + public void EnumerateDifferences_DateValues_DateFormatString(string dateFormatString, int expectedMs, int actualMs, bool expectEqual) + { + var expected = new PrimitiveValuesTestClass + { + DateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, expectedMs), + NullableDateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, expectedMs), + DateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, expectedMs, TimeSpan.Zero), + NullableDateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, expectedMs, TimeSpan.Zero), + }; + var actual = new PrimitiveValuesTestClass + { + DateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, actualMs), + NullableDateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, actualMs), + DateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, actualMs, TimeSpan.Zero), + NullableDateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, actualMs, TimeSpan.Zero), + }; + + var differences = JsonDeepEqualDiff.EnumerateDifferences(expected, actual, new JsonDeepEqualDiffOptions { DateFormatString = dateFormatString }); + if (expectEqual) + { + Assert.Empty(differences); + } + else + { + Assert.NotEmpty(differences); + } + } + + [Theory] + [InlineData(123, 456, true)] + [InlineData(000, 001, true)] + [InlineData(998, 999, true)] + [InlineData(000, 999, false)] + [InlineData(498, 499, true)] + [InlineData(499, 500, false)] + [InlineData(500, 501, true)] + public void EnumerateDifferences_DateValues_DateTimeConverter_RoundSeconds(int expectedMs, int actualMs, bool expectEqual) + { + var expected = new PrimitiveValuesTestClass + { + DateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, expectedMs), + NullableDateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, expectedMs), + DateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, expectedMs, TimeSpan.Zero), + NullableDateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, expectedMs, TimeSpan.Zero), + }; + var actual = new PrimitiveValuesTestClass + { + DateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, actualMs), + NullableDateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, actualMs), + DateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, actualMs, TimeSpan.Zero), + NullableDateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, actualMs, TimeSpan.Zero), + }; + + var differences = JsonDeepEqualDiff.EnumerateDifferences(expected, actual, new JsonDeepEqualDiffOptions + { + DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFK", + DateTimeConverter = (dt) => + { + var roundedSecond = dt.Second + (dt.Millisecond >= 500 ? 1 : 0); + return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, roundedSecond, dt.Kind); + }, + }); + if (expectEqual) + { + Assert.Empty(differences); + } + else + { + Assert.NotEmpty(differences); + } + } + + [Theory] + [InlineData(123, 456, false)] + [InlineData(000, 001, true)] + [InlineData(000, 999, false)] + [InlineData(001, 002, false)] + [InlineData(003, 004, true)] + [InlineData(997, 998, true)] + [InlineData(998, 999, false)] + public void EnumerateDifferences_DateValues_DateTimeConverter_SqlDateTime(int expectedMs, int actualMs, bool expectEqual) + { + var expected = new PrimitiveValuesTestClass + { + DateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, expectedMs), + NullableDateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, expectedMs), + DateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, expectedMs, TimeSpan.Zero), + NullableDateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, expectedMs, TimeSpan.Zero), + }; + var actual = new PrimitiveValuesTestClass + { + DateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, actualMs), + NullableDateTimeValue = new DateTime(2002, 2, 2, 12, 22, 22, actualMs), + DateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, actualMs, TimeSpan.Zero), + NullableDateTimeOffsetValue = new DateTimeOffset(2002, 2, 2, 12, 22, 22, actualMs, TimeSpan.Zero), + }; + + var differences = JsonDeepEqualDiff.EnumerateDifferences(expected, actual, new JsonDeepEqualDiffOptions + { + DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK", + DateTimeConverter = (dt) => new System.Data.SqlTypes.SqlDateTime(dt).Value, + }); + if (expectEqual) + { + Assert.Empty(differences); + } + else + { + Assert.NotEmpty(differences); + } + } + } +} diff --git a/JsonDeepEqual.Tests/JsonDiffNodeTest.cs b/JsonDeepEqual.Tests/JsonDiffNodeTest.cs new file mode 100644 index 0000000..589d5d7 --- /dev/null +++ b/JsonDeepEqual.Tests/JsonDiffNodeTest.cs @@ -0,0 +1,246 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Two.JsonDeepEqual +{ + public class JsonDiffNodeTest + { + [Theory] + [InlineData(2, "2")] + [InlineData(2.0f, "2.0")] + [InlineData(2.123f, "2.123")] + [InlineData(2.0d, "2.0")] + [InlineData(2.123d, "2.123")] + [InlineData("", "\"\"")] + [InlineData("2", "\"2\"")] + [InlineData("Hello world!", "\"Hello world!\"")] + [InlineData("\"hi\"", "\"\\\"hi\\\"\"")] + [InlineData(@"domain\user", @"""domain\\user""")] + [InlineData(null, "null")] + [InlineData(true, "true")] + [InlineData(false, "false")] + public void ToString_SimpleValue(object value, string expectedValue) + { + var valueJToken = value != null ? JToken.FromObject(value) : JValue.CreateNull(); + var difference = new JsonDiffNode("/Test", valueJToken, null); + var actualMessage = difference.ToString(); + + var expectedMessage = $@"/Test: + Expected: {expectedValue} + Actual: null"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_StringValues() + { + var difference = new JsonDiffNode("/Test", JToken.FromObject("Hello, World"), JToken.FromObject("Hello, blorld")); + var actualMessage = difference.ToString(); + + var expectedMessage = @"/Test: + ↓ (pos 8) + Expected: ""Hello, World"" + Actual: ""Hello, blorld"" + ↑ (pos 8)"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_StringValues_CaseDifference() + { + var difference = new JsonDiffNode("/Test", JToken.FromObject("Hello, World"), JToken.FromObject("Hello, world")); + var actualMessage = difference.ToString(); + + var expectedMessage = @"/Test: + ↓ (pos 8) + Expected: ""Hello, World"" + Actual: ""Hello, world"" + ↑ (pos 8)"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_StringValues_NoDifference() + { + var difference = new JsonDiffNode("/Test", JToken.FromObject("Hello, World"), JToken.FromObject("Hello, World")); + var actualMessage = difference.ToString(); + + var expectedMessage = @"/Test: + Expected: ""Hello, World"" + Actual: ""Hello, World"""; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_DecimalValues() + { + var difference = new JsonDiffNode("/Test", JToken.FromObject(2.123m), JToken.FromObject(2m)); + var actualMessage = difference.ToString(); + + var expectedMessage = $@"/Test: + Expected: 2.123 + Actual: 2.0"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_EnumValues_WithStringEnumConverter() + { + var jsonSerializer = new JsonSerializer(); + jsonSerializer.Converters.Add(new StringEnumConverter()); + var difference = new JsonDiffNode("/Test", JToken.FromObject(StringComparison.OrdinalIgnoreCase, jsonSerializer), JToken.FromObject(StringComparison.Ordinal, jsonSerializer)); + var actualMessage = difference.ToString(); + + var expectedMessage = @"/Test: + ↓ (pos 8) + Expected: ""OrdinalIgnoreCase"" + Actual: ""Ordinal"" + ↑ (pos 8)"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_EnumValues_WithoutStringEnumConverter() + { + var difference = new JsonDiffNode("/Test", JToken.FromObject(StringComparison.OrdinalIgnoreCase), JToken.FromObject(StringComparison.Ordinal)); + var actualMessage = difference.ToString(); + + var expectedMessage = @"/Test: + Expected: 5 + Actual: 4"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_EnumValues_MissingName() + { + var jsonSerializer = new JsonSerializer(); + jsonSerializer.Converters.Add(new StringEnumConverter()); + var difference = new JsonDiffNode("/Test", JToken.FromObject((StringComparison)2222, jsonSerializer), JToken.FromObject((StringComparison)22222, jsonSerializer)); + var actualMessage = difference.ToString(); + + var expectedMessage = @"/Test: + Expected: 2222 + Actual: 22222"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_ByteArrayValues() + { + var difference = new JsonDiffNode("/Test", JToken.FromObject(new byte[] { 1, 171, 128, 3 }), JToken.FromObject(new byte[] { 2 })); + var actualMessage = difference.ToString(); + + var expectedMessage = $@"/Test: + ↓ (pos 2) + Expected: ""AauAAw=="" + Actual: ""Ag=="" + ↑ (pos 2)"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_LongStringValues_DifferenceInMiddle() + { + var expectedValue = string.Join(string.Empty, Enumerable.Range(0, 512)); + var actualValue = string.Join(string.Empty, Enumerable.Range(0, 256)) + string.Join(string.Empty, Enumerable.Range(0, 256)); + // Assert.Equal('"' + expectedValue + '"', '"' + actualValue + '"'); + + var difference = new JsonDiffNode("/Test", JToken.FromObject(expectedValue), JToken.FromObject(actualValue)); + var actualMessage = difference.ToString(); + + var expectedMessage = $@"/Test: + ↓ (pos 659) + Expected: …4925025125225325425525625725825926026126226326426526626726826… + Actual: …4925025125225325425501234567891011121314151617181920212223242… + ↑ (pos 659)"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_LongStringValues_DifferenceInMiddle_BarelyTruncated() + { + var str1 = string.Join(string.Empty, Enumerable.Range(0, 20).Select(i => (i % 10).ToString(CultureInfo.InvariantCulture))); + var str2 = string.Join(string.Empty, Enumerable.Range(0, 40).Select(i => (i % 10).ToString(CultureInfo.InvariantCulture))); + + var expectedValue = str1 + 'a' + str2; + var actualValue = str1 + 'b' + str2; + // Assert.Equal('"' + expectedValue + '"', '"' + actualValue + '"'); + + var difference = new JsonDiffNode("/Test", JToken.FromObject(expectedValue), JToken.FromObject(actualValue)); + var actualMessage = difference.ToString(); + + var expectedMessage = $@"/Test: + ↓ (pos 21) + Expected: …01234567890123456789a0123456789012345678901234567890123456789… + Actual: …01234567890123456789b0123456789012345678901234567890123456789… + ↑ (pos 21)"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_LongStringValues_DifferenceInMiddle_NotTruncated() + { + var str1 = string.Join(string.Empty, Enumerable.Range(0, 19).Select(i => (i % 10).ToString(CultureInfo.InvariantCulture))); + var str2 = string.Join(string.Empty, Enumerable.Range(0, 39).Select(i => (i % 10).ToString(CultureInfo.InvariantCulture))); + + var expectedValue = str1 + 'a' + str2; + var actualValue = str1 + 'b' + str2; + // Assert.Equal('"' + expectedValue + '"', '"' + actualValue + '"'); + + var difference = new JsonDiffNode("/Test", JToken.FromObject(expectedValue), JToken.FromObject(actualValue)); + var actualMessage = difference.ToString(); + + var expectedMessage = $@"/Test: + ↓ (pos 20) + Expected: ""0123456789012345678a012345678901234567890123456789012345678"" + Actual: ""0123456789012345678b012345678901234567890123456789012345678"" + ↑ (pos 20)"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_LongStringValues_DifferenceAtStart() + { + var expectedValue = string.Join(string.Empty, Enumerable.Range(0, 512)); + var actualValue = string.Join(string.Empty, Enumerable.Range(1, 512)); + // Assert.Equal('"' + expectedValue + '"', '"' + actualValue + '"'); + + var difference = new JsonDiffNode("/Test", JToken.FromObject(expectedValue), JToken.FromObject(actualValue)); + var actualMessage = difference.ToString(); + + var expectedMessage = $@"/Test: + ↓ (pos 1) + Expected: ""01234567891011121314151617181920212223242… + Actual: ""12345678910111213141516171819202122232425… + ↑ (pos 1)"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + + [Fact] + public void ToString_LongStringValues_DifferenceAtEnd() + { + var expectedValue = string.Join(string.Empty, Enumerable.Range(0, 512)) + "ab"; + var actualValue = string.Join(string.Empty, Enumerable.Range(0, 512)) + "ac"; + // Assert.Equal('"' + expectedValue + '"', '"' + actualValue + '"'); + + var difference = new JsonDiffNode("/Test", JToken.FromObject(expectedValue), JToken.FromObject(actualValue)); + var actualMessage = difference.ToString(); + + var expectedMessage = $@"/Test: + ↓ (pos 1428) + Expected: …5506507508509510511ab"" + Actual: …5506507508509510511ac"" + ↑ (pos 1428)"; + Assert.Equal(expectedMessage, actualMessage, ignoreLineEndingDifferences: true); + } + } +} diff --git a/JsonDeepEqual.Tests/JsonDiffTest.cs b/JsonDeepEqual.Tests/JsonDiffTest.cs new file mode 100644 index 0000000..37b7655 --- /dev/null +++ b/JsonDeepEqual.Tests/JsonDiffTest.cs @@ -0,0 +1,51 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Xunit; + +namespace Two.JsonDeepEqual +{ + public class JsonDiffTest + { + [Fact] + public void EnumerateJTokenDifferences_DirectCircularReferenceObjects_NoDifferences() + { + var expected = CreateDirectCircularReferenceObject(); + var actual = CreateDirectCircularReferenceObject(); + var differences = JsonDiff.EnumerateDifferences(expected, actual).ToList(); + Assert.Empty(differences); + } + + private static JObject CreateDirectCircularReferenceObject() + { + var o = new JObject(); + o.Add(new JProperty("self", o)); + return o; + } + + [Fact] + public void EnumerateJTokenDifferences_ParentChildCircularReferenceObjects_NoDifferences() + { + var expected = CreateParentChildCircularReferenceObject(); + var actual = CreateParentChildCircularReferenceObject(); + var differences = JsonDiff.EnumerateDifferences(expected, actual).ToList(); + Assert.Empty(differences); + } + + private static JObject CreateParentChildCircularReferenceObject() + { + var parentObject = new JObject(); + parentObject.Add(new JProperty("name", "parent")); + + var childObject = new JObject(); + childObject.Add(new JProperty("name", "child")); + + parentObject.Add(new JProperty("child", childObject)); + childObject.Add(new JProperty("parent", parentObject)); + + return parentObject; + } + } +} diff --git a/JsonDeepEqual.Tests/TestEntities/Company.cs b/JsonDeepEqual.Tests/TestEntities/Company.cs new file mode 100644 index 0000000..d2299c4 --- /dev/null +++ b/JsonDeepEqual.Tests/TestEntities/Company.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +#pragma warning disable CA1044 // Properties should not be write only +#pragma warning disable CA1819 // Properties should not return arrays +#pragma warning disable CA2227 // Collection properties should be read only +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable SA1011 // Closing square brackets should be spaced correctly + +namespace Two.JsonDeepEqual +{ + public class Company + { + public int Id { get; set; } + + public string? Name { get; set; } + + public ICollection? Employees { get; set; } + } + + public class CompanyPrivateGetters + { + public int Id { private get; set; } + + public string? Name { private get; set; } + } + + public class Employee : Person + { + public string? EmployeeIdentifier { get; set; } + } + + public class Person + { + public int Id { get; set; } + + public string? FullName { get; set; } + + public Address? HomeAddress => Addresses?.FirstOrDefault(a => a.AddressType == AddressType.Home); + + public ICollection
? Addresses { get; set; } + + public Phone? HomePhone => Phones?.FirstOrDefault(p => p.PhoneType == PhoneType.Home); + + public ICollection? Phones { get; set; } + + public Person? Father { get; set; } + + public Person? Mother { get; set; } + + public ICollection? Spouses { get; set; } + + public ICollection? Children { get; set; } + } + + public class Address + { + public int Id { get; set; } + + public AddressType? AddressType { get; set; } + + public ICollection? Lines { get; set; } + } + + public enum AddressType + { + Home, + Work, + } + + public class Phone + { + public int Id { get; set; } + + public PhoneType? PhoneType { get; set; } + + public string? Number { get; set; } + } + + public enum PhoneType + { + Home, + Cell, + Work, + } +} diff --git a/JsonDeepEqual.Tests/TestEntities/TestClasses.cs b/JsonDeepEqual.Tests/TestEntities/TestClasses.cs new file mode 100644 index 0000000..37731ed --- /dev/null +++ b/JsonDeepEqual.Tests/TestEntities/TestClasses.cs @@ -0,0 +1,200 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Reflection; + +#pragma warning disable CA1819 // Properties should not return arrays +#pragma warning disable CA2227 // Collection properties should be read only +#pragma warning disable SA1011 // Closing square brackets should be spaced correctly +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable SA1649 // File name should match first type name + +namespace Two.JsonDeepEqual +{ + public class TestClass1 + { + public int Id { get; set; } + + public string? Name { get; set; } + + public byte[]? BinaryData { get; set; } + + public TestClass1Child[]? ChildArray { get; set; } + + public ICollection? ChildCollection { get; set; } + } + + public class TestClass1Child + { + public int ChildId { get; set; } + } + + public class TestClass2WithoutJsonAttributes + { + public int Id { get; set; } + + public string? Name { get; set; } + } + + public class TestClass2WithJsonAttributes + { + [JsonIgnore] + public int Id { get; set; } + + [JsonProperty("description")] + public string? Name { get; set; } + } + + public class SelfReferenceTestClass + { + public SelfReferenceTestClass? Child { get; set; } + } + + public class ListTestClass + { + public IEnumerable Enumerable { get; set; } = System.Array.Empty(); + + public IReadOnlyCollection ReadOnlyCollection { get; set; } = System.Array.Empty(); + + public ICollection Collection { get; set; } = System.Array.Empty(); + + public T[] Array { get; set; } = System.Array.Empty(); + + public IList IList { get; set; } = System.Array.Empty(); + + public List List { get; set; } = new List(); + } + + public class DictionaryTestClass + where TKey : notnull + { + public IReadOnlyDictionary? Dictionary { get; set; } + + public IReadOnlyDictionary>? DictionaryOfEnumerables { get; set; } + + public IReadOnlyDictionary>? DictionaryOfCollections { get; set; } + } + + public class ReflectionValuesTestClass + { + public Type? Type { get; set; } + + public Type? TypeGetOnly => Type; + + public PropertyInfo? PropertyInfo { get; set; } + + public PropertyInfo? PropertyInfoGetOnly => PropertyInfo; + } + + public class PrimitiveValuesTestClass + { + public static PrimitiveValuesTestClass CreateSample() + { + return new PrimitiveValuesTestClass + { + StringValue = "two", + IntValue = 1, + NullableIntValue = 2, + UIntValue = 3, + NullableUIntValue = 4, + LongValue = 5L, + NullableLongValue = 6L, + ULongValue = 7L, + NullableULongValue = 8L, + ShortValue = 9, + NullableShortValue = 10, + UShortValue = 11, + NullableUShortValue = 12, + ByteValue = 13, + NullableByteValue = 14, + SByteValue = 15, + NullableSByteValue = 16, + BoolValue = true, + NullableBoolValue = false, + DecimalValue = 1.1m, + NullableDecimalValue = 2.2m, + DoubleValue = 3.3d, + NullableDoubleValue = 4.4d, + FloatValue = 5.5d, + NullableFloatValue = 6.6d, + DateTimeValue = new DateTime(2000, 1, 1, 12, 1, 2), + NullableDateTimeValue = new DateTime(2000, 1, 2, 12, 1, 2), + DateTimeOffsetValue = new DateTimeOffset(2000, 1, 3, 12, 1, 2, TimeSpan.Zero), + NullableDateTimeOffsetValue = new DateTimeOffset(2000, 1, 4, 12, 1, 2, TimeSpan.Zero), + TimeSpanValue = TimeSpan.FromHours(1), + NullableTimeSpanValue = TimeSpan.FromHours(2), + GuidValue = new Guid("e9e13594-6cb4-45f0-b20b-b4e947161256"), + NullableGuidValue = new Guid("36408fcb-eb6c-4440-95ed-39ec43866347"), + ByteArrayValue = new byte[] { 1, 2, 255 }, + }; + } + + public string? StringValue { get; set; } + + public int IntValue { get; set; } + + public int? NullableIntValue { get; set; } + + public uint UIntValue { get; set; } + + public uint? NullableUIntValue { get; set; } + + public long LongValue { get; set; } + + public long? NullableLongValue { get; set; } + + public ulong ULongValue { get; set; } + + public ulong? NullableULongValue { get; set; } + + public short ShortValue { get; set; } + + public short? NullableShortValue { get; set; } + + public ushort UShortValue { get; set; } + + public ushort? NullableUShortValue { get; set; } + + public byte ByteValue { get; set; } + + public byte? NullableByteValue { get; set; } + + public sbyte SByteValue { get; set; } + + public sbyte? NullableSByteValue { get; set; } + + public bool BoolValue { get; set; } + + public bool? NullableBoolValue { get; set; } + + public decimal DecimalValue { get; set; } + + public decimal? NullableDecimalValue { get; set; } + + public double DoubleValue { get; set; } + + public double? NullableDoubleValue { get; set; } + + public double FloatValue { get; set; } + + public double? NullableFloatValue { get; set; } + + public DateTime DateTimeValue { get; set; } + + public DateTime? NullableDateTimeValue { get; set; } + + public DateTimeOffset DateTimeOffsetValue { get; set; } + + public DateTimeOffset? NullableDateTimeOffsetValue { get; set; } + + public TimeSpan TimeSpanValue { get; set; } + + public TimeSpan? NullableTimeSpanValue { get; set; } + + public Guid GuidValue { get; set; } + + public Guid? NullableGuidValue { get; set; } + + public byte[]? ByteArrayValue { get; set; } + } +} diff --git a/JsonDeepEqual.Tests/Two.JsonDeepEqual.Tests.csproj b/JsonDeepEqual.Tests/Two.JsonDeepEqual.Tests.csproj new file mode 100644 index 0000000..a143395 --- /dev/null +++ b/JsonDeepEqual.Tests/Two.JsonDeepEqual.Tests.csproj @@ -0,0 +1,30 @@ + + + + netcoreapp3.0 + Two.JsonDeepEqual + CodeAnalysis.ruleset + 1701;1702;1591 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/JsonDeepEqual.sln b/JsonDeepEqual.sln new file mode 100644 index 0000000..32cd5ee --- /dev/null +++ b/JsonDeepEqual.sln @@ -0,0 +1,56 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29503.13 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Two.JsonDeepEqual", "JsonDeepEqual\Two.JsonDeepEqual.csproj", "{F07B657E-31F5-4826-AB33-93CC313B4699}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3C420FF2-D0D0-4B57-8598-FA1A5D6168E6}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitattributes = .gitattributes + .gitignore = .gitignore + CodeAnalysis.ruleset = CodeAnalysis.ruleset + Directory.Build.props = Directory.Build.props + LICENSE = LICENSE + README.md = README.md + stylecop.json = stylecop.json + UNLICENSE = UNLICENSE + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Two.JsonDeepEqual.Tests", "JsonDeepEqual.Tests\Two.JsonDeepEqual.Tests.csproj", "{179C4E35-4CCA-448A-8E32-3A68DB3104F0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{CE4524ED-76BC-4844-AE89-C9EBCAFBC47F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Two.JsonDeepEqual.SampleConsole", "JsonDeepEqual.SampleConsole\Two.JsonDeepEqual.SampleConsole.csproj", "{1B1E307B-13D6-481D-961F-A19FE4A8F8DF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F07B657E-31F5-4826-AB33-93CC313B4699}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F07B657E-31F5-4826-AB33-93CC313B4699}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F07B657E-31F5-4826-AB33-93CC313B4699}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F07B657E-31F5-4826-AB33-93CC313B4699}.Release|Any CPU.Build.0 = Release|Any CPU + {179C4E35-4CCA-448A-8E32-3A68DB3104F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {179C4E35-4CCA-448A-8E32-3A68DB3104F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {179C4E35-4CCA-448A-8E32-3A68DB3104F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {179C4E35-4CCA-448A-8E32-3A68DB3104F0}.Release|Any CPU.Build.0 = Release|Any CPU + {1B1E307B-13D6-481D-961F-A19FE4A8F8DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B1E307B-13D6-481D-961F-A19FE4A8F8DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B1E307B-13D6-481D-961F-A19FE4A8F8DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B1E307B-13D6-481D-961F-A19FE4A8F8DF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {179C4E35-4CCA-448A-8E32-3A68DB3104F0} = {CE4524ED-76BC-4844-AE89-C9EBCAFBC47F} + {1B1E307B-13D6-481D-961F-A19FE4A8F8DF} = {CE4524ED-76BC-4844-AE89-C9EBCAFBC47F} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1ABF8733-ED06-4967-940D-168266544431} + EndGlobalSection +EndGlobal diff --git a/JsonDeepEqual/Exceptions/JsonEqualException.cs b/JsonDeepEqual/Exceptions/JsonEqualException.cs new file mode 100644 index 0000000..40e2b1c --- /dev/null +++ b/JsonDeepEqual/Exceptions/JsonEqualException.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Two.JsonDeepEqual.Exceptions +{ + /// + /// Exception thrown when two JSON values are unexpectedly not equal. + /// + public class JsonEqualException : Exception + { + /// + /// Constructs a default . + /// + public JsonEqualException() + : this(differences: null, message: null, innerException: null) { } + + /// + /// Constructs a default with a custom message. + /// + /// The error message that explains the reason for the exception, or null. + public JsonEqualException(string? message) + : this(differences: null, message: message, innerException: null) { } + + /// + /// Constructs a new instance of with a custom message + /// and a references to the inner exception that caused this exception. + /// + /// The error message that explains the reason for the exception, or null. + /// The exception that is the cause of the current exception, or null. + public JsonEqualException(string? message, Exception? innerException) + : this(differences: null, message: message, innerException: innerException) { } + + /// + /// Constructs a new instance of with the specified differences. + /// + /// The differences that caused this exception. + public JsonEqualException(IReadOnlyCollection? differences) + : this(differences, message: null, innerException: null) { } + + /// + /// Constructs a new instance of with the specified differences and a custom message. + /// + /// The differences that caused this exception. + /// The error message that explains the reason for the exception, or null. + public JsonEqualException(IReadOnlyCollection? differences, string? message) + : this(differences, message, innerException: null) { } + + /// + /// Constructs a new instance of with the specified differences, a custom message, + /// and a references to the inner exception that caused this exception. + /// + /// The differences that caused this exception. + /// The error message that explains the reason for the exception, or null. + /// The exception that is the cause of the current exception, or null. + public JsonEqualException(IReadOnlyCollection? differences, string? message, Exception? innerException) + : base(message ?? "JsonAssert.Equal() Failure", innerException) + { + Differences = differences ?? Array.Empty(); + } + + /// + /// The differences that caused this exception. + /// + public IReadOnlyCollection Differences { get; } + + /// + /// The base message, without the description of the . + /// + protected string BaseMessage => base.Message; + + /// + /// A message the includes the base message and a description of the . + /// + public override string Message + { + get + { + if (message == null) + { + message = GenerateMessageFromDifferences(Differences, base.Message); + } + return message; + } + } + + private string? message; + + private static string GenerateMessageFromDifferences(IReadOnlyCollection differences, string? baseMessage) + { + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(baseMessage)) + { + sb.Append(baseMessage); + } + foreach (var difference in differences) + { + sb.AppendLine(); + sb.Append(difference.ToString()); + } + return sb.ToString(); + } + + /// + public override string ToString() + { + var result = GetType().ToString(); + + var message = Message; + if (!string.IsNullOrEmpty(message)) + { + result += ": " + message; + } + + var stackTrace = StackTrace; + if (!string.IsNullOrEmpty(stackTrace)) + { + result += Environment.NewLine + stackTrace; + } + return result; + } + } +} diff --git a/JsonDeepEqual/Exceptions/JsonNotEqualException.cs b/JsonDeepEqual/Exceptions/JsonNotEqualException.cs new file mode 100644 index 0000000..aa1caa3 --- /dev/null +++ b/JsonDeepEqual/Exceptions/JsonNotEqualException.cs @@ -0,0 +1,32 @@ +using System; + +namespace Two.JsonDeepEqual.Exceptions +{ + /// + /// Exception thrown when two JSON values are unexpectedly equal. + /// + public class JsonNotEqualException : Exception + { + /// + /// Constructs a default . + /// + public JsonNotEqualException() + : this(message: null, innerException: null) { } + + /// + /// Constructs a new instance of with a custom message. + /// + /// The error message that explains the reason for the exception, or null. + public JsonNotEqualException(string? message) + : this(message, innerException: null) { } + + /// + /// Constructs a new instance of with a custom message + /// and a references to the inner exception that caused this exception. + /// + /// The error message that explains the reason for the exception, or null. + /// The exception that is the cause of the current exception, or null. + public JsonNotEqualException(string? message, Exception? innerException) + : base(message ?? "JsonDeepEqualAssert.NotEqual() Failure", innerException) { } + } +} diff --git a/JsonDeepEqual/JsonAssert.cs b/JsonDeepEqual/JsonAssert.cs new file mode 100644 index 0000000..2de60eb --- /dev/null +++ b/JsonDeepEqual/JsonAssert.cs @@ -0,0 +1,209 @@ +using Newtonsoft.Json.Linq; +using System; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using Two.JsonDeepEqual.Exceptions; + +namespace Two.JsonDeepEqual +{ + /// + /// Verifies whether two JSON documents are equal. + /// + public static class JsonAssert + { + /// + /// Verifies that two JSON strings are equal. + /// + /// The expected value. + /// The value to be compared against. + /// Thrown when the JSON strings are not equal. + public static void Equal(string? expected, string? actual) + => Equal(expected, actual, options: null); + + /// + /// Verifies that two JSON strings are equal. + /// + /// The expected value. + /// The value to be compared against. + /// Options that affect the comparison, or null to use default options. + /// Thrown when the JSON strings are not equal. + public static void Equal(string? expected, string? actual, JsonDiffOptions? options) + { + const int maxDifferenceCount = 20; + var differences = JsonDiff.EnumerateDifferences(expected, actual, options).Take(maxDifferenceCount + 1).ToList(); + if (differences.Any()) + { + var differenceCount = differences.Count; + var differenceCountString = differenceCount > maxDifferenceCount ? $"{maxDifferenceCount}+" : differenceCount.ToString(CultureInfo.InvariantCulture); + throw new JsonEqualException(differences, $"JsonAssert.Equal() Failure: {differenceCountString} difference{(differences.Count != 1 ? "s" : string.Empty)}"); + } + } + + /// + /// Verifies that two values are equal. + /// + /// The expected . + /// The to be compared against. + /// Thrown when the values are not equal. + public static void Equal(JToken? expected, JToken? actual) + => Equal(expected, actual, options: null); + + /// + /// Verifies that two values are equal. + /// + /// The expected . + /// The to be compared against. + /// Options that affect the comparison, or null to use default options. + /// Thrown when the values are not equal. + public static void Equal(JToken? expected, JToken? actual, JsonDiffOptions? options) + { + const int maxDifferenceCount = 20; + var differences = JsonDiff.EnumerateDifferences(expected, actual, options).Take(maxDifferenceCount + 1).ToList(); + if (differences.Any()) + { + var differenceCountString = differences.Count > maxDifferenceCount ? $"{maxDifferenceCount}+" : maxDifferenceCount.ToString(CultureInfo.InvariantCulture); + throw new JsonEqualException(differences, $"JsonAssert.Equal() Failure: {differenceCountString} difference{(differences.Count != 1 ? "s" : string.Empty)}"); + } + } + + /// + /// Verifies that two JSON strings are not equal. + /// + /// The expected value. + /// The value to be compared against. + /// Thrown when the JSON strings are equal. + public static void NotEqual(string? expected, string? actual) + => NotEqual(expected, actual, options: null); + + /// + /// Verifies that two JSON strings are not equal. + /// + /// The expected value. + /// The value to be compared against. + /// Options that affect the comparison, or null to use default options. + /// Thrown when the JSON strings are equal. + public static void NotEqual(string? expected, string? actual, JsonDiffOptions? options) + { + var differences = JsonDiff.EnumerateDifferences(expected, actual, options); + if (!differences.Any()) + { + throw new JsonNotEqualException(); + } + } + + /// + /// Verifies that two values are not equal. + /// + /// The expected . + /// The to be compared against. + /// Thrown when the values are equal. + public static void NotEqual(JToken? expected, JToken? actual) + => NotEqual(expected, actual, options: null); + + /// + /// Verifies that two values are not equal. + /// + /// The expected . + /// The to be compared against. + /// Options that affect the comparison, or null to use default options. + /// Thrown when the values are equal. + public static void NotEqual(JToken? expected, JToken? actual, JsonDiffOptions? options) + { + var differences = JsonDiff.EnumerateDifferences(expected, actual, options); + if (!differences.Any()) + { + throw new JsonNotEqualException(); + } + } + + #region Aliases + + /// + /// An alias of for consistency with NUnit's Assert.AreEqual method. + /// + /// The expected value. + /// The value to be compared against. + /// Thrown when the JSON strings are not equal. + public static void AreEqual(string? expected, string? actual) + => Equal(expected, actual, options: null); + + /// + /// An alias of for consistency with NUnit's Assert.AreEqual method. + /// + /// The expected value. + /// The value to be compared against. + /// Options that affect the comparison, or null to use default options. + /// Thrown when the JSON strings are not equal. + public static void AreEqual(string? expected, string? actual, JsonDiffOptions? options) + => Equal(expected, actual, options); + + /// + /// An alias of for consistency with NUnit's Assert.AreEqual method. + /// + /// The expected . + /// The to be compared against. + /// Thrown when the values are not equal. + public static void AreEqual(JToken? expected, JToken? actual) + => Equal(expected, actual, options: null); + + /// + /// An alias of for consistency with NUnit's Assert.AreEqual method. + /// + /// The expected . + /// The to be compared against. + /// Options that affect the comparison, or null to use default options. + /// Thrown when the values are not equal. + public static void AreEqual(JToken? expected, JToken? actual, JsonDiffOptions? options) + => Equal(expected, actual, options); + + /// + /// An alias of for consistency with NUnit's Assert.AreNotEqual method. + /// + /// The expected value. + /// The value to be compared against. + /// Thrown when the JSON strings are equal. + public static void AreNotEqual(string? expected, string? actual) + => NotEqual(expected, actual, options: null); + + /// + /// An alias of for consistency with NUnit's Assert.AreNotEqual method. + /// + /// The expected value. + /// The value to be compared against. + /// Options that affect the comparison, or null to use default options. + /// Thrown when the JSON strings are equal. + public static void AreNotEqual(string? expected, string? actual, JsonDiffOptions? options) + => NotEqual(expected, actual, options); + + #endregion + + #region Not Supported +#pragma warning disable SA1611 // Element parameters should be documented +#pragma warning disable SA1615 // Element return value should be documented + + /// + /// Do not call this method. + /// + [Obsolete("This is an override of Object.Equals(). Call JsonAssert.Equal() instead.", true)] + [EditorBrowsable(EditorBrowsableState.Never)] + public static new bool Equals(object a, object b) + { + throw new NotSupportedException("JsonAssert.Equals should not be used"); + } + + /// + /// Do not call this method. + /// + [Obsolete("This is an override of Object.ReferenceEquals(). Call JsonAssert.Equal() instead.", true)] + [EditorBrowsable(EditorBrowsableState.Never)] + public static new bool ReferenceEquals(object a, object b) + { + throw new NotSupportedException("JsonAssert.ReferenceEquals should not be used"); + } + +#pragma warning restore SA1615 // Element return value should be documented +#pragma warning restore SA1611 // Element parameters should be documented + #endregion + } +} diff --git a/JsonDeepEqual/JsonDeepEqualAssert.cs b/JsonDeepEqual/JsonDeepEqualAssert.cs new file mode 100644 index 0000000..ca664ed --- /dev/null +++ b/JsonDeepEqual/JsonDeepEqualAssert.cs @@ -0,0 +1,137 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using Two.JsonDeepEqual.Exceptions; + +namespace Two.JsonDeepEqual +{ + /// + /// Verifies whether two objects are equal based on their JSON representation. + /// + public static class JsonDeepEqualAssert + { + /// + /// Verifies that two objects are equal, using a JSON serialization comparer. + /// + /// The expected value. + /// The value to be compared against. + /// Thrown when the objects are not equal. + public static void Equal(object? expected, object? actual) + => Equal(expected, actual, options: null); + + /// + /// Verifies that two objects are equal, using a JSON serialization comparer. + /// + /// The expected value. + /// The value to be compared against. + /// Options that control the comparison, or null to use default options. + /// Thrown when the objects are not equal. + public static void Equal(object? expected, object? actual, JsonDeepEqualDiffOptions? options) + { + const int maxDifferenceCount = 20; + var differences = JsonDeepEqualDiff.EnumerateDifferences(expected, actual, options).Take(maxDifferenceCount + 1).ToList(); + if (differences.Any()) + { + var differenceCount = differences.Count; + var differenceCountString = differenceCount > maxDifferenceCount ? $"{maxDifferenceCount}+" : differenceCount.ToString(CultureInfo.InvariantCulture); + throw new JsonEqualException(differences, $"JsonDeepEqualAssert.Equal() Failure: {differenceCountString} difference{(differences.Count != 1 ? "s" : string.Empty)}"); + } + } + + /// + /// Verifies that two objects are not equal, using a JSON serialization comparer. + /// + /// The expected object. + /// The actual object. + /// Thrown when the objects are equal. + public static void NotEqual(object? expected, object? actual) + => NotEqual(expected, actual, options: null); + + /// + /// Verifies that two objects are not equal, using a JSON serialization comparer. + /// + /// The expected object. + /// The actual object. + /// Options that control the comparison, or null to use default options. + /// Thrown when the objects are equal. + public static void NotEqual(object? expected, object? actual, JsonDeepEqualDiffOptions? options) + { + var differences = JsonDeepEqualDiff.EnumerateDifferences(expected, actual, options); + if (!differences.Any()) + { + throw new JsonNotEqualException(); + } + } + + #region Aliases + + /// + /// An alias of for consistency with NUnit's Assert.AreEqual method. + /// + /// The expected value. + /// The value to be compared against. + /// Thrown when the objects are not equal. + public static void AreEqual(object? expected, object? actual) + => Equal(expected, actual, options: null); + + /// + /// An alias of for consistency with NUnit's Assert.AreEqual method. + /// + /// The expected value. + /// The value to be compared against. + /// Options that control the comparison, or null to use default options. + /// Thrown when the objects are not equal. + public static void AreEqual(object? expected, object? actual, JsonDeepEqualDiffOptions? options) + => Equal(expected, actual, options); + + /// + /// An alias of for consistency with NUnit's Assert.AreNotEqual method. + /// + /// The expected object. + /// The actual object. + /// Thrown when the objects are equal. + public static void AreNotEqual(object? expected, object? actual) + => NotEqual(expected, actual, options: null); + + /// + /// An alias of for consistency with NUnit's Assert.AreNotEqual method. + /// + /// The expected object. + /// The actual object. + /// Options that control the comparison, or null to use default options. + /// Thrown when the objects are equal. + public static void AreNotEqual(object? expected, object? actual, JsonDeepEqualDiffOptions? options) + => NotEqual(expected, actual, options); + + #endregion + + #region Not Supported +#pragma warning disable SA1611 // Element parameters should be documented +#pragma warning disable SA1615 // Element return value should be documented + + /// + /// Do not call this method. + /// + [Obsolete("This is an override of Object.Equals(). Call JsonDeepEqualAssert.Equal() instead.", true)] + [EditorBrowsable(EditorBrowsableState.Never)] + public static new bool Equals(object a, object b) + { + throw new NotSupportedException("JsonDeepEqualAssert.Equals should not be used"); + } + + /// + /// Do not call this method. + /// + [Obsolete("This is an override of Object.ReferenceEquals(). Call JsonDeepEqualAssert.Equal() instead.", true)] + [EditorBrowsable(EditorBrowsableState.Never)] + public static new bool ReferenceEquals(object a, object b) + { + throw new NotSupportedException("JsonDeepEqualAssert.ReferenceEquals should not be used"); + } + +#pragma warning restore SA1615 // Element return value should be documented +#pragma warning restore SA1611 // Element parameters should be documented + #endregion + } +} diff --git a/JsonDeepEqual/JsonDeepEqualDiff.cs b/JsonDeepEqual/JsonDeepEqualDiff.cs new file mode 100644 index 0000000..2abd931 --- /dev/null +++ b/JsonDeepEqual/JsonDeepEqualDiff.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json.Linq; +using System.Collections.Generic; + +namespace Two.JsonDeepEqual +{ + /// + /// Finds the differences between two objects based on their JSON representation. + /// + public static class JsonDeepEqualDiff + { + /// + /// Finds the differences between two objects, using a JSON serialization comparer. + /// + /// The expected value. + /// The value to be compared against. + /// The differences, or an empty enumerable if the two objects are equal. + public static IEnumerable EnumerateDifferences(object? expected, object? actual) + => EnumerateDifferences(expected, actual, options: null); + + /// + /// Finds the differences between two objects, using a JSON serialization comparer. + /// + /// The expected value. + /// The value to be compared against. + /// Options that control the comparison, or null to use default options. + /// The differences, or an empty enumerable if the two objects are equal. + public static IEnumerable EnumerateDifferences(object? expected, object? actual, JsonDeepEqualDiffOptions? options) + { + if (options == null) + { + options = new JsonDeepEqualDiffOptions(); + } + + var jsonSerializer = options.ToJsonSerializer(); + var expectedJToken = expected != null ? JToken.FromObject(expected, jsonSerializer) : null; + var actualJToken = actual != null ? JToken.FromObject(actual, jsonSerializer) : null; + + var results = JsonDiff.EnumerateDifferences(expectedJToken, actualJToken, options); + return results; + } + } +} diff --git a/JsonDeepEqual/JsonDeepEqualDiffOptions.cs b/JsonDeepEqual/JsonDeepEqualDiffOptions.cs new file mode 100644 index 0000000..de1fbad --- /dev/null +++ b/JsonDeepEqual/JsonDeepEqualDiffOptions.cs @@ -0,0 +1,318 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using Two.JsonDeepEqual.Utilities; + +namespace Two.JsonDeepEqual +{ + /// + /// Options that control how two objects are serialized to JSON and compared. + /// + public class JsonDeepEqualDiffOptions : JsonDiffOptions + { + /// + /// Property names to exclude from the comparison, with support for glob-style wildcards (* and **). + /// These properties will be ignored in all objects. + /// + public IReadOnlyCollection? ExcludePropertyNames { get; set; } + + /// + /// A custom filter that chooses the properties included in the comparison. + /// This is a more advanced alternative to the property. + /// + public Func, IEnumerable>? PropertyFilter { get; set; } + + /// + /// Specifies how null values are handled during JSON serialization. + /// The default is . + /// + public NullValueHandling? NullValueHandling { get; set; } + + /// + /// Specifies how default values are handled during JSON serialization. + /// The default is . + /// + public DefaultValueHandling? DefaultValueHandling { get; set; } + + /// + /// Specifies how circular references are handled during JSON serialization. + /// The default is . + /// + public ReferenceLoopHandling? ReferenceLoopHandling { get; set; } + + /// + /// Specifies how and values + /// are formatted when writing JSON text. + /// Default is "yyyy'-'MM'-'dd'T'HH':'mm':'ssK". + /// + public string? DateFormatString { get; set; } + + /// + /// Adjusts a value before JSON serialization. + /// This can be used to choose the precision of DateTime values (such as truncating or rounding milliseconds). + /// + public Func? DateTimeConverter { get; set; } + + /// + /// Sepcifies how JSON attributes (like and ) are handled during JSON serialization. + /// The default is . + /// + public JsonAttributeHandling? JsonAttributeHandling { get; set; } + + /// + /// Any custom converters to use during JSON serialization. + /// + public IReadOnlyCollection? JsonConverters { get; set; } + + /// + /// Creates a for these options. + /// + internal JsonSerializer ToJsonSerializer() + { + var options = this; + var jsonSerializer = new JsonSerializer + { + NullValueHandling = options.NullValueHandling ?? Newtonsoft.Json.NullValueHandling.Ignore, + DefaultValueHandling = options.DefaultValueHandling ?? Newtonsoft.Json.DefaultValueHandling.Ignore, + ReferenceLoopHandling = options.ReferenceLoopHandling ?? Newtonsoft.Json.ReferenceLoopHandling.Ignore, + DateFormatString = options.DateFormatString ?? "yyyy'-'MM'-'dd'T'HH':'mm':'ssK", + }; + + if (options.JsonConverters != null) + { + foreach (var jsonConverter in options.JsonConverters) + { + jsonSerializer.Converters.Add(jsonConverter); + } + } + jsonSerializer.Converters.Add(new StringEnumConverter()); + jsonSerializer.Converters.Add(new JsonDeepEqualDateTimeConverter(options.DateTimeConverter)); + + jsonSerializer.ContractResolver = new JsonDeepEqualContractResolver(options); + + return jsonSerializer; + } + + /// + /// Creates a for these options, or returns null if no filter is needed. + /// + internal Func, IEnumerable>? ToJsonPropertyFilterOrNull() + { + var options = this; + + Func, IEnumerable>? excludePropertyNameFilter = null; + if (options.ExcludePropertyNames != null && options.ExcludePropertyNames.Any()) + { + excludePropertyNameFilter = new JsonPropertyFilter(options.ExcludePropertyNames).Apply; + } + + Func, IEnumerable>? customFilter = options.PropertyFilter; + if (excludePropertyNameFilter != null && customFilter != null) + { + return new AggregateJsonPropertyFilter(excludePropertyNameFilter, customFilter).Apply; + } + return excludePropertyNameFilter ?? customFilter; + } + } + + /// + /// Handling options for JSON attributes (like and ). + /// + public enum JsonAttributeHandling + { + /// + /// True if JSON attributes should affect serialization. + /// + Include = 0, + + /// + /// Ignore JSON attributes when serializing and deserializing objects. + /// + Ignore = 1, + } + + /// + /// A filter on the JSON properties to include in a comparison. + /// + internal interface IJsonPropertyFilter + { + /// + /// Filters the given properties to only the properties that should be included in a comparison. + /// + /// The properties to filter. + /// The properties that should be included in the comparison. + IEnumerable Apply(IEnumerable properties); + } + + /// + /// A for the property. + /// + internal sealed class JsonPropertyFilter : IJsonPropertyFilter + { + private readonly IReadOnlyCollection excludeNames; + private readonly IReadOnlyCollection excludeNameRegexes; + + public JsonPropertyFilter(IReadOnlyCollection excludeNames) + { + this.excludeNames = excludeNames + .Where(path => !GlobConvert.IsGlobPattern(path)) + .Where(path => !string.IsNullOrEmpty(path)) + .ToArray(); + this.excludeNameRegexes = excludeNames + .Where(GlobConvert.IsGlobPattern) + .Select(globPattern => GlobConvert.CreatePathRegexOrNull(globPattern, ignoreCase: true)) + .Where(regex => regex != null).Cast() + .ToArray(); + } + + /// + public IEnumerable Apply(IEnumerable properties) + { + var result = properties; + foreach (var excludeName in excludeNames) + { + result = result.Where(property => !string.Equals(property.PropertyName, excludeName, StringComparison.OrdinalIgnoreCase)); + } + foreach (var excludeNameRegex in excludeNameRegexes) + { + result = result.Where(property => !excludeNameRegex.IsMatch(property.PropertyName)); + } + return result; + } + } + + /// + /// A that applies two or more filters. + /// + internal sealed class AggregateJsonPropertyFilter : IJsonPropertyFilter + { + public AggregateJsonPropertyFilter(params Func, IEnumerable>[] filters) + : this((IReadOnlyCollection, IEnumerable>>)filters) { } + + public AggregateJsonPropertyFilter(IReadOnlyCollection, IEnumerable>> filters) + { + InnerFilters = filters ?? throw new ArgumentNullException(nameof(filters)); + if (!InnerFilters.Any()) + { + throw new ArgumentException("Must have at least one inner filter"); + } + } + + /// + /// The filters that are applied by this aggregate filter. + /// + public IReadOnlyCollection, IEnumerable>> InnerFilters { get; } + + /// + public IEnumerable Apply(IEnumerable properties) + { + var result = properties; + foreach (var filter in InnerFilters) + { + result = filter(result); + } + return result; + } + } + + /// + /// A custom contract resolver that attempt to ignore JSON attributes that modify the properties + /// (like and ) + /// and applies any property filters. + /// + internal class JsonDeepEqualContractResolver : DefaultContractResolver + { + private readonly Func, IEnumerable>? jsonPropertyFilterOrNull; + private readonly JsonAttributeHandling jsonAttributeHandling; + + public JsonDeepEqualContractResolver() + : this(null) { } + + public JsonDeepEqualContractResolver(JsonDeepEqualDiffOptions? options) + { + this.jsonPropertyFilterOrNull = options?.ToJsonPropertyFilterOrNull(); + this.jsonAttributeHandling = options?.JsonAttributeHandling ?? JsonAttributeHandling.Ignore; + } + + /// + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + + if (jsonAttributeHandling == JsonAttributeHandling.Ignore) + { + // Don't allow attributes to change the property name. + property.PropertyName = property.UnderlyingName; + + // Don't allow attributes to ignore properties. + if (property.Ignored) + { + property.Ignored = false; + } + } + + // Properties can be ignored by an optional filter. + if (!property.Ignored && jsonPropertyFilterOrNull != null && !jsonPropertyFilterOrNull(new[] { property }).Any()) + { + property.Ignored = true; + } + + return property; + } + } + + /// + /// A custom subclass of that supports custom date/time conversion. + /// + internal class JsonDeepEqualDateTimeConverter : DateTimeConverterBase + { + public JsonDeepEqualDateTimeConverter(Func? dateTimeConverter) + { + DateTimeConverter = dateTimeConverter; + } + + /// + /// An optional converter for values that adjusts them before serialization. + /// + public Func? DateTimeConverter { get; } + + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + string text; + if (value is DateTime dateTime) + { + dateTime = DateTimeConverter?.Invoke(dateTime) ?? dateTime; + text = dateTime.ToString(serializer.DateFormatString, CultureInfo.InvariantCulture); + } + else if (value is DateTimeOffset dateTimeOffset) + { + if (DateTimeConverter != null) + { + var dateTimePart = dateTimeOffset.DateTime; + dateTimePart = DateTimeConverter(dateTimePart); + dateTimeOffset = new DateTimeOffset(dateTimePart, dateTimeOffset.Offset); + } + text = dateTimeOffset.ToString(serializer.DateFormatString, CultureInfo.InvariantCulture); + } + else + { + throw new JsonSerializationException($"Unexpected value when converting date. Expected DateTime or DateTimeOffset, got {value?.GetType().FullName ?? "null"}."); + } + writer.WriteValue(text); + } + + /// + public override bool CanRead => false; + + /// + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + => throw new NotSupportedException(); + } +} diff --git a/JsonDeepEqual/JsonDiff.cs b/JsonDeepEqual/JsonDiff.cs new file mode 100644 index 0000000..f0f6555 --- /dev/null +++ b/JsonDeepEqual/JsonDiff.cs @@ -0,0 +1,285 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Two.JsonDeepEqual +{ + /// + /// Finds the differences between two JSON documents. + /// + public static class JsonDiff + { + /// + /// Finds the differences between two JSON strings. + /// + /// The expected JSON string. + /// The JSON string to be compared against. + /// The differences, or an empty enumerable if the two JSON strings are equal. + public static IEnumerable EnumerateDifferences(string? expectedJson, string? actualJson) + => EnumerateDifferences(expectedJson, actualJson, options: null); + + /// + /// Finds the differences between two JSON strings. + /// + /// The expected JSON string. + /// The JSON string to be compared against. + /// Options that control the comparison, or null to use default options. + /// The differences, or an empty enumerable if the two JSON strings are equal. + public static IEnumerable EnumerateDifferences(string? expectedJson, string? actualJson, JsonDiffOptions? options) + => new JsonDiffer(options).EnumerateJsonDifferences(expectedJson, actualJson); + + /// + /// Finds the differences between two values. + /// + /// The expected . + /// The to be compared against. + /// The differences, or an empty enumerable if the values are equal. + public static IEnumerable EnumerateDifferences(JToken? expectedJToken, JToken? actualJToken) + => EnumerateDifferences(expectedJToken, actualJToken, options: null); + + /// + /// Finds the differences between two values. + /// + /// The expected . + /// The to be compared against. + /// Options that control the comparison, or null to use default options. + /// The differences, or an empty enumerable if the values are equal. + public static IEnumerable EnumerateDifferences(JToken? expectedJToken, JToken? actualJToken, JsonDiffOptions? options) + => new JsonDiffer(options).EnumerateJTokenDifferences(expectedJToken, actualJToken); + + private sealed class JsonDiffer + { + private readonly JsonDiffOptions options; + private readonly Func, IEnumerable>? jsonPropertyPathFilterOrNull; + + public JsonDiffer(JsonDiffOptions? options) + { + this.options = options ?? new JsonDiffOptions(); + this.jsonPropertyPathFilterOrNull = options?.ToJsonPropertyPathFilterOrNull(); + } + + public IEnumerable EnumerateJsonDifferences(string? expectedJson, string? actualJson) + { + if (expectedJson != null && actualJson != null) + { + if (options.IgnoreLineEndingDifferences) + { + expectedJson = Regex.Replace(expectedJson, "\r\n?", "\n"); + actualJson = Regex.Replace(expectedJson, "\r\n?", "\n"); + } + if (options.IgnoreWhiteSpaceDifferences) + { + expectedJson = Regex.Replace(expectedJson, @"\s+", " "); + actualJson = Regex.Replace(expectedJson, @"\s+", " "); + } + if (options.IgnoreCase) + { +#pragma warning disable CA1308 // Normalize strings to uppercase + expectedJson = expectedJson.ToLowerInvariant(); + actualJson = actualJson.ToLowerInvariant(); +#pragma warning restore CA1308 // Normalize strings to uppercase + } + } + + var expectedJToken = expectedJson != null ? JToken.Parse(expectedJson) : null; + var actualJToken = actualJson != null ? JToken.Parse(actualJson) : null; + return EnumerateJTokenDifferencesRecursive(expectedJToken, actualJToken, path: string.Empty); + } + + public IEnumerable EnumerateJTokenDifferences(JToken? expectedJToken, JToken? actualJToken) + { + bool isStringManipulationOption = options.IgnoreLineEndingDifferences || options.IgnoreWhiteSpaceDifferences || options.IgnoreCase; + if (isStringManipulationOption && expectedJToken != null && actualJToken != null) + { + var expectedJson = expectedJToken.ToString(Newtonsoft.Json.Formatting.None); + var actualJson = actualJToken.ToString(Newtonsoft.Json.Formatting.None); + return EnumerateJsonDifferences(expectedJson, actualJson); + } + + return EnumerateJTokenDifferencesRecursive(expectedJToken, actualJToken, path: string.Empty); + } + + private IEnumerable EnumerateJTokenDifferencesRecursive(JToken? expected, JToken? actual, string path) + { + if (expected == null) + { + expected = JValue.CreateNull(); + } + if (actual == null) + { + actual = JValue.CreateNull(); + } + + IEnumerable results; + if (expected.Type == JTokenType.Object && actual.Type == JTokenType.Object) + { + results = EnumerateJObjectDifferencesRecursiveUnfiltered((JObject)expected, (JObject)actual, path); + } + else if (expected.Type == JTokenType.Array && actual.Type == JTokenType.Array) + { + results = EnumerateJArrayDifferencesRecursiveUnfiltered((JArray)expected, (JArray)actual, path); + } + else if (JToken.DeepEquals(expected, actual)) + { + results = Enumerable.Empty(); + } + else + { + var valueDifference = new JsonDiffNode(path, expected, actual); + results = new[] { valueDifference }; + } + + if (jsonPropertyPathFilterOrNull != null) + { + var filteredPaths = new HashSet(jsonPropertyPathFilterOrNull(results.Select(x => x.Path))); + results = results.Where(result => filteredPaths.Contains(result.Path)); + } + + return results; + } + + private IEnumerable EnumerateJObjectDifferencesRecursiveUnfiltered(JObject expected, JObject actual, string path) + { + var expectedProperties = expected.Properties(); + int propertyMatchCount = 0; + foreach (var expectedProperty in expectedProperties) + { + JToken expectedValue = expectedProperty.Value; + JToken? actualValue; + + var actualPropertyOrNull = actual.Property(expectedProperty.Name, StringComparison.Ordinal); + if (actualPropertyOrNull != null) + { + actualValue = actualPropertyOrNull.Value; + propertyMatchCount++; + } + else + { + actualValue = null; + } + + var differences = EnumerateJTokenDifferencesRecursive(expectedValue, actualValue, path + "/" + expectedProperty.Name); + foreach (var difference in differences) + { + yield return difference; + } + } + + var actualProperties = actual.Properties(); + if (propertyMatchCount != actualProperties.Count()) + { + foreach (var actualProperty in actualProperties) + { + var expectedPropertyOrNull = expected.Property(actualProperty.Name, StringComparison.Ordinal); + if (expectedPropertyOrNull != null) + { + continue; + } + + JToken actualValue = actualProperty.Value; + JToken? expectedValue = null; + + var differences = EnumerateJTokenDifferencesRecursive(expectedValue, actualValue, path + "/" + actualProperty.Name); + foreach (var difference in differences) + { + yield return difference; + } + } + } + } + + private IEnumerable EnumerateJArrayDifferencesRecursiveUnfiltered(JArray expected, JArray actual, string path) + { + var expectedCount = expected.Count; + var actualCount = actual.Count; + + if (expectedCount == 0 && actualCount == 0) + { + yield break; + } + + bool ignoreArrayElementOrder = options.IgnoreArrayElementOrder; + if (!ignoreArrayElementOrder) + { + var minCount = Math.Min(expectedCount, actualCount); + for (var i = 0; i < minCount; i++) + { + var expectedElement = expected[i]; + var actualElement = actual[i]; + var elementDifferences = EnumerateJTokenDifferencesRecursive(expectedElement, actualElement, path + "/" + i); + foreach (var elementDifference in elementDifferences) + { + yield return elementDifference; + } + } + if (expectedCount > minCount) + { + for (var i = minCount; i < expectedCount; i++) + { + var expectedElement = expected[i]; + var elementDifferences = EnumerateJTokenDifferencesRecursive(expectedElement, null, path + "/" + i); + foreach (var elementDifference in elementDifferences) + { + yield return elementDifference; + } + } + } + if (actualCount > minCount) + { + for (var i = minCount; i < actualCount; i++) + { + var actualElement = actual[i]; + var elementDifferences = EnumerateJTokenDifferencesRecursive(null, actualElement, path + "/" + i); + foreach (var elementDifference in elementDifferences) + { + yield return elementDifference; + } + } + } + } + else + { + var unmatchedExpected = expected.ToList(); + var unmatchedActual = actual.ToList(); + foreach (var expectedElement in expected) + { + JToken? matchedActualElement = null; + foreach (var actualElement in unmatchedActual) + { + var differences = EnumerateJTokenDifferencesRecursive(expectedElement, actualElement, path + "/*"); + if (!differences.Any()) + { + matchedActualElement = actualElement; + break; + } + } + if (matchedActualElement != null) + { + unmatchedExpected.Remove(expectedElement); + unmatchedActual.Remove(matchedActualElement); + } + if (!unmatchedActual.Any()) + { + break; + } + } + + // An unordered array difference doesn't really fit into the JSON pointer system. + // The "*" wildcard path thing is non-standard and kind of questionable, but we'll go with it for now. + if (unmatchedExpected.Any() || unmatchedActual.Any()) + { + var arrayDifference = new JsonDiffNode(path + "/*", JArray.FromObject(unmatchedExpected), JArray.FromObject(unmatchedActual)); + yield return arrayDifference; + } + if (expectedCount != actualCount) + { + var countDifference = new JsonDiffNode(path + "/length", JToken.FromObject(expectedCount), JToken.FromObject(actualCount)); + yield return countDifference; + } + } + } + } + } +} diff --git a/JsonDeepEqual/JsonDiffNode.cs b/JsonDeepEqual/JsonDiffNode.cs new file mode 100644 index 0000000..3f3e163 --- /dev/null +++ b/JsonDeepEqual/JsonDiffNode.cs @@ -0,0 +1,222 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Linq; +using System.Text; + +namespace Two.JsonDeepEqual +{ + /// + /// A difference at a path between two JSON documents. + /// + public class JsonDiffNode + { + /// + /// Constructs a . + /// + /// The path to this difference in the JSON document as a [JSON pointer](https://tools.ietf.org/html/rfc6901). + /// The expected value at the . + /// The actual value at the at the . + public JsonDiffNode(string path, JToken? expectedValue, JToken? actualValue) + { + Path = path ?? throw new ArgumentNullException(nameof(path)); + ExpectedValue = expectedValue ?? JValue.CreateNull(); + ActualValue = actualValue ?? JValue.CreateNull(); + } + + /// + /// The path to this difference in the JSON document as a [JSON pointer](https://tools.ietf.org/html/rfc6901). + /// + public string Path { get; } + + /// + /// The value from the expected document at the . + /// + public JToken ExpectedValue { get; } + + /// + /// The value from the actual at the . + /// + public JToken ActualValue { get; } + + #region Computed + + /// + /// The character index of the first difference in the serialized values, or null if the diff index is not known or applicable. + /// + public virtual int? DiffIndex => GetOrCreateJsonDifferenceDisplay().DiffIndex; + + /// + /// The display value for . + /// This may be truncated for values with a long string representation. + /// + public virtual string ExpectedValueDisplay => GetOrCreateJsonDifferenceDisplay().ExpectedValueDisplay; + + /// + /// The character index in of the first difference, or null if there is no known difference index. + /// This may differ from if the is truncated. + /// + public virtual int? ExpectedValueDisplayDiffIndex => GetOrCreateJsonDifferenceDisplay().ExpectedValueDisplayDiffIndex; + + /// + /// The display value for . + /// This may be truncated for values with a long string representation. + /// + public virtual string ActualValueDisplay => GetOrCreateJsonDifferenceDisplay().ActualValueDisplay; + + /// + /// The character index in of the first difference, or null if there is no known difference index. + /// This may differ from if the is truncated. + /// + public virtual int? ActualValueDisplayDiffIndex => GetOrCreateJsonDifferenceDisplay().ActualValueDisplayDiffIndex; + + /// + /// Returns a message that describes this difference. + /// + /// The full description of this difference. + public override string ToString() => GetOrCreateJsonDifferenceDisplay().ToStringValue; + + private JsonDiffNodeDisplay GetOrCreateJsonDifferenceDisplay() + { + if (jsonDiffNodeDisplayOrNull != null) + { + return jsonDiffNodeDisplayOrNull; + } + + string expectedValue = ExpectedValue.ToString(Formatting.None); + string actualValue = ActualValue.ToString(Formatting.None); + var diffIndex = FindDiffIndex(expectedValue, actualValue); + var (expectedValueDisplay, expectedValueDisplayDiffIndex) = GenerateDisplayValue(expectedValue, diffIndex); + var (actualValueDisplay, actualValueDisplayDiffIndex) = GenerateDisplayValue(actualValue, diffIndex); + + bool showDiffIndexPointers = diffIndex.HasValue + && expectedValueDisplayDiffIndex.HasValue + && actualValueDisplayDiffIndex.HasValue + && ExpectedValue != null + && ActualValue != null + && ExpectedValue.Type == ActualValue.Type + && new[] { JTokenType.Object, JTokenType.Array, JTokenType.String, JTokenType.Bytes, JTokenType.Raw }.Contains(ExpectedValue.Type); + + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(Path)) + { + sb.Append(Path).Append(":"); + sb.AppendLine(); + } + if (showDiffIndexPointers && expectedValueDisplayDiffIndex.HasValue) + { + sb.Append(" ").Append(' ', expectedValueDisplayDiffIndex.Value).Append('↓').Append($" (pos {diffIndex})"); + sb.AppendLine(); + } + sb.Append(" Expected: ").Append(expectedValueDisplay); + sb.AppendLine(); + sb.Append(" Actual: ").Append(actualValueDisplay); + if (showDiffIndexPointers && actualValueDisplayDiffIndex.HasValue) + { + sb.AppendLine(); + sb.Append(" ").Append(' ', actualValueDisplayDiffIndex.Value).Append('↑').Append($" (pos {diffIndex})"); + } + var toStringValue = sb.ToString(); + + jsonDiffNodeDisplayOrNull = new JsonDiffNodeDisplay( + expectedValueDisplay: expectedValueDisplay, + expectedValueDisplayDiffIndex: expectedValueDisplayDiffIndex, + actualValueDisplay: actualValueDisplay, + actualValueDisplayDiffIndex: actualValueDisplayDiffIndex, + diffIndex: diffIndex, + toStringValue: toStringValue + ); + return jsonDiffNodeDisplayOrNull; + } + + private JsonDiffNodeDisplay? jsonDiffNodeDisplayOrNull; + + private class JsonDiffNodeDisplay + { + public JsonDiffNodeDisplay(string expectedValueDisplay, int? expectedValueDisplayDiffIndex, string actualValueDisplay, int? actualValueDisplayDiffIndex, int? diffIndex, string toStringValue) + { + ExpectedValueDisplay = expectedValueDisplay ?? throw new ArgumentNullException(nameof(expectedValueDisplay)); + ExpectedValueDisplayDiffIndex = expectedValueDisplayDiffIndex; + ActualValueDisplay = actualValueDisplay ?? throw new ArgumentNullException(nameof(actualValueDisplay)); + ActualValueDisplayDiffIndex = actualValueDisplayDiffIndex; + DiffIndex = diffIndex; + ToStringValue = toStringValue ?? throw new ArgumentNullException(nameof(toStringValue)); + } + + public string ExpectedValueDisplay { get; } + + public int? ExpectedValueDisplayDiffIndex { get; } + + public string ActualValueDisplay { get; } + + public int? ActualValueDisplayDiffIndex { get; } + + public int? DiffIndex { get; } + + public string ToStringValue { get; } + } + + private static int? FindDiffIndex(string expected, string actual) + { + int? differenceIndex = null; + + var expectedLength = expected.Length; + var actualLength = actual.Length; + var minLength = Math.Min(expectedLength, actualLength); + for (var i = 0; i < minLength; i++) + { + if (expected[i] != actual[i]) + { + differenceIndex = i; + break; + } + } + if (!differenceIndex.HasValue && expectedLength != actualLength) + { + differenceIndex = minLength; + } + return differenceIndex; + } + + private static Tuple GenerateDisplayValue(string value, int? diffIndexOrNull) + { + const int beforeDiffLength = 20; + const int afterDiffLength = 40; + const int maxLength = beforeDiffLength + afterDiffLength + 1; + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + { + return Tuple.Create(value ?? string.Empty, diffIndexOrNull); + } + + string valueDisplay; + int? displayDiffIndex; + if (!diffIndexOrNull.HasValue) + { + valueDisplay = value.Substring(0, maxLength) + "…"; + displayDiffIndex = null; + } + else + { + int diffIndex = diffIndexOrNull.Value; + int startIndex = Math.Max(diffIndex - beforeDiffLength, 0); + int endIndex = Math.Min(diffIndex + afterDiffLength + 1, value.Length); + valueDisplay = value.Substring(startIndex, endIndex - startIndex); + + int displayDiffIndexValue = diffIndex; + if (startIndex > 0) + { + displayDiffIndexValue = diffIndex + 1 - startIndex; + valueDisplay = "…" + valueDisplay; + } + if (endIndex < value.Length) + { + valueDisplay += "…"; + } + displayDiffIndex = displayDiffIndexValue; + } + return Tuple.Create(valueDisplay, displayDiffIndex); + } + + #endregion + } +} diff --git a/JsonDeepEqual/JsonDiffOptions.cs b/JsonDeepEqual/JsonDiffOptions.cs new file mode 100644 index 0000000..e01b715 --- /dev/null +++ b/JsonDeepEqual/JsonDiffOptions.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Two.JsonDeepEqual.Utilities; + +namespace Two.JsonDeepEqual +{ + /// + /// Options that control how two JSON values are compared. + /// + public class JsonDiffOptions + { + /// + /// Paths to exclude from the comparison, in [JSON pointer notation](https://tools.ietf.org/html/rfc6901) with support for glob-style wildcards (* and **). + /// + public IReadOnlyCollection? ExcludePropertyPaths { get; set; } + + /// + /// A custom filter that chooses the paths to include in the comparison. + /// This is a more advanced alternative to the property. + /// + public Func, IEnumerable>? PropertyPathFilter { get; set; } + + /// + /// If true, two arrays will be considered equal if they contain the same elements in any order. + /// + public bool IgnoreArrayElementOrder { get; set; } + + /// + /// If true, ignores case differences in all string values and property names. + /// + public bool IgnoreCase { get; set; } + + /// + /// If true, treats treats \r\n, \r, and \n as equivalent in all string values. + /// + public bool IgnoreLineEndingDifferences { get; set; } + + /// + /// If true, treats spaces, tabs, and other whitespace in any non-zero quantity as equivalent. + /// + public bool IgnoreWhiteSpaceDifferences { get; set; } + + /// + /// Creates a for these options, or returns null if no filter is needed. + /// + internal Func, IEnumerable>? ToJsonPropertyPathFilterOrNull() + { + var options = this; + Func, IEnumerable>? excludePropertyPathFilter = null; + if (options.ExcludePropertyPaths != null && options.ExcludePropertyPaths.Any()) + { + excludePropertyPathFilter = new JsonPropertyPathFilter(options.ExcludePropertyPaths).Apply; + } + + Func, IEnumerable>? customFilter = options.PropertyPathFilter; + if (excludePropertyPathFilter != null && customFilter != null) + { + return new AggregateJsonPropertyPathFilter(excludePropertyPathFilter, customFilter).Apply; + } + return excludePropertyPathFilter ?? customFilter; + } + } + + /// + /// A filter on JSON property paths. + /// + internal interface IJsonPropertyPathFilter + { + /// + /// Filters the given property paths, which are in [JSON pointer notation](https://tools.ietf.org/html/rfc6901). + /// + /// The property paths to filter. + /// The property paths that should be included in a comparison. + IEnumerable Apply(IEnumerable propertyPaths); + } + + /// + /// A for the property. + /// + internal sealed class JsonPropertyPathFilter : IJsonPropertyPathFilter + { + private readonly IReadOnlyCollection excludePaths; + private readonly IReadOnlyCollection excludePathRegexes; + + public JsonPropertyPathFilter(IReadOnlyCollection excludePropertyPaths) + { + this.excludePaths = excludePropertyPaths + .Where(path => !GlobConvert.IsGlobPattern(path)) + .Where(path => !string.IsNullOrEmpty(path)) + .ToArray(); + this.excludePathRegexes = excludePropertyPaths + .Where(GlobConvert.IsGlobPattern) + .Select(globPattern => GlobConvert.CreatePathRegexOrNull(globPattern, ignoreCase: true)) + .Where(regex => regex != null).Cast() + .ToArray(); + } + + /// + public IEnumerable Apply(IEnumerable propertyPaths) + { + var result = propertyPaths; + foreach (var excludePath in excludePaths) + { + result = result.Where(propertyPath => !string.Equals(propertyPath, excludePath, StringComparison.OrdinalIgnoreCase)); + } + foreach (var excludePathRegex in excludePathRegexes) + { + result = result.Where(propertyPath => !excludePathRegex.IsMatch(propertyPath)); + } + return result; + } + } + + /// + /// A that applies two or more filters. + /// + internal sealed class AggregateJsonPropertyPathFilter : IJsonPropertyPathFilter + { + public AggregateJsonPropertyPathFilter(params Func, IEnumerable>[] filters) + : this((IReadOnlyCollection, IEnumerable>>)filters) { } + + public AggregateJsonPropertyPathFilter(IReadOnlyCollection, IEnumerable>> filters) + { + this.InnerFilters = filters ?? throw new ArgumentNullException(nameof(filters)); + if (!this.InnerFilters.Any()) + { + throw new ArgumentException("Must have at least one filter"); + } + } + + /// + /// The filters that are applied by this aggregate filter. + /// + public IReadOnlyCollection, IEnumerable>> InnerFilters { get; } + + /// + public IEnumerable Apply(IEnumerable properties) + { + var result = properties; + foreach (var filter in InnerFilters) + { + result = filter(result); + } + return result; + } + } +} diff --git a/JsonDeepEqual/Two.JsonDeepEqual.csproj b/JsonDeepEqual/Two.JsonDeepEqual.csproj new file mode 100644 index 0000000..fd0111b --- /dev/null +++ b/JsonDeepEqual/Two.JsonDeepEqual.csproj @@ -0,0 +1,28 @@ + + + + netstandard2.1;netstandard2.0 + 8.0 + 22222 + Compares values for deep equality using JSON serialization. + https://github.com/22222/JsonDeepEqual + MIT OR Unlicense + testing compare equal equality deep-equal deep-equals + + + + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/JsonDeepEqual/Utilities/GlobConvert.cs b/JsonDeepEqual/Utilities/GlobConvert.cs new file mode 100644 index 0000000..6556272 --- /dev/null +++ b/JsonDeepEqual/Utilities/GlobConvert.cs @@ -0,0 +1,74 @@ +using System; +using System.Text.RegularExpressions; + +namespace Two.JsonDeepEqual.Utilities +{ + /// + /// Utility methods for glob patterns. + /// + internal static class GlobConvert + { + private static readonly char[] GlobChars = new char[] { '*', '?' }; + + /// + /// Returns true if the given pattern contains any glob characters (like *). + /// + /// The pattern to check, or null. + /// True if the pattern contains any characters with special handling in a glob pattern. + public static bool IsGlobPattern(string? pattern) + { + return pattern != null && pattern.IndexOfAny(GlobChars) >= 0; + } + + /// + /// Returns true if the given pattern should be treated as an absolute path. + /// + /// The pattern to check, or null. + /// True if the pattern starts with a directory separator character. + public static bool IsAbsolutePathPattern(string? pattern) + { + if (pattern == null || pattern.Length == 0) + { + return false; + } + return pattern[0] == '/' || pattern[0] == '\\'; + } + + /// + /// Converts a glob pattern to a regular expression, or returns null if the global pattern is null or empty. + /// + /// The glob pattern to convert to a regular expression. + /// True if the regular expression should be case insensitive. + /// The or null. + public static Regex? CreatePathRegexOrNull(string? globPattern, bool ignoreCase) + { + if (string.IsNullOrEmpty(globPattern)) + { + return null; + } + + var escapedPattern = Regex.Escape(globPattern) + "$"; + if (IsAbsolutePathPattern(globPattern)) + { + escapedPattern = "^" + escapedPattern; + } + else + { + escapedPattern = @"(?:^|/|\\)" + escapedPattern; + } + +#pragma warning disable CA1307 // Specify StringComparison + var regexPattern = escapedPattern + .Replace(@"\*\*", ".*") + .Replace(@"\*", @"[^/\\]*") + .Replace(@"\?", @"[^/\\]"); +#pragma warning restore CA1307 // Specify StringComparison + var regexOptions = RegexOptions.None; + if (ignoreCase) + { + regexOptions |= RegexOptions.IgnoreCase; + } + return new Regex(regexPattern, regexOptions); + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c310738 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 22222 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..97a2657 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +A .NET library for comparing values based on their JSON representation. + + +Overview +======== +This library compares values for deep equality, which it defines as: + +> Two objects are equal if they have the same JSON representation when serialized by [Json.NET](https://github.com/JamesNK/Newtonsoft.Json). + +That means all of the hard work is done by the Json.NET library. And Json.NET actually provides a [JToken.DeepEquals method](https://www.newtonsoft.com/json/help/html/DeepEquals.htm) that you could use directly. + +So what does this library have to offer? It provides additional features that are specific to comparing two values, including: + +- Detailed descriptions of any detected differences +- Comparison options (exclude properties, ignore array order, etc.) +- Convenience methods that accept objects as parameters instead of JToken values + + +Installation +============ +You have a few options for installing this library: + +- Install the [NuGet package](https://www.nuget.org/packages/Two.JsonDeepEqual/) +- Download the assembly from the [latest release](https://github.com/22222/JsonDeepEqual/releases/latest) and reference it manually +- Copy the source code directly into your project + +This project is available under either of two licenses: [MIT](LICENSE) or [The Unlicense](UNLICENSE). The goal is to allow you to copy any of the source code from this library into your own project without having to worry about attribution or any other licensing complexity. + + +Getting Started +=============== +You can use the the `JsonDeepEqualAssert.Equal` static method to compare two objects for equality: + +```c# +using Two.JsonDeepEqual; + +var expected = new +{ + Message = "Hello!", + Child = new { Id = 1, Values = new[] { 1, 2, 3 } }, +}; +var actual = new +{ + Message = "Hello, World!", + Child = new { Id = 2, Values = new[] { 1, 4, 3 } }, +}; +JsonDeepEqualAssert.Equal(expected, actual); +``` + +That example throws an exception with a message like: + +```text +JsonDeepEqualAssert.Equal() Failure: 3 differences +/Message: + ↓ (pos 6) + Expected: "Hello!" + Actual: "Hello, World!" + ↑ (pos 6) +/Child/Id: + Expected: 1 + Actual: 2 +/Child/Values/1: + Expected: 2 + Actual: 4 +``` + +You can provide options that change how the objects are compared: + +```c# +using Two.JsonDeepEqual; + +var expected = new +{ + Id = 1, + Message = "Hello!", + Child = new { Id = 10, Values = new[] { 1, 2, 3 } }, + Created = new DateTime(2002, 2, 2, 12, 22, 23), +}; +var actual = new +{ + Id = 2, + Message = "Hello, World!", + Child = new { Id = 11, Values = new[] { 1, 4, 3 } }, + Created = new DateTime(2002, 2, 2, 12, 22, 22, 999), +}; +JsonDeepEqualAssert.Equal(expected, actual, new JsonDeepEqualDiffOptions +{ + ExcludePropertyNames = new[] { "Id", "Mess*" }, + ExcludePropertyPaths = new[] { "**/Values/*" }, + IgnoreArrayElementOrder = true, + DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFK", + DateTimeConverter = (DateTime dt) => new System.Data.SqlTypes.SqlDateTime(dt).Value, +}); +``` + +There's a similar `NotEqual` method that throws an exception when the objects are equal: + +```c# +JsonDeepEqualAssert.NotEqual(expected, actual); +``` + +You can use the `JsonAssert.Equal` static method to compare JSON strings or JToken values directly: + +```c# +var expectedJson = @"{ ""a"":1 }"; +var actualJson = @"{ ""a"":2 }"; +JsonAssert.Equal(expectedJson, actualJson); +``` + +Aliases of `AreEqual` and `AreNotEqual` are available for all of these methods if you want a name that's more consistent with NUnit assert methods: + +```c# +JsonDeepEqualAssert.AreEqual(expected, actual); +JsonDeepEqualAssert.AreNotEqual(expected, actual); +``` + + +Options +======= +All of the Equal, NotEqual, and EnumerateDifferences methods accept an optional "options" parameter that gives you more control over the equality comparisons. + +Example: + +```c# +JsonDeepEqualAssert.Equal(expected, actual, new JsonDeepEqualDiffOptions +{ + ExcludePropertyNames = new[] { "Id", "*DateTime" }, + PropertyFilter = (IEnumerable properties) => properties.Where(p => p.PropertyName != "Id" && p.PropertyType != typeof(DateTime)), + NullValueHandling = NullValueHandling.Include, + DefaultValueHandling = DefaultValueHandling.Include, + ReferenceLoopHandling = ReferenceLoopHandling.Error, + DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFK", + DateTimeConverter = (DateTime dt) => new System.Data.SqlTypes.SqlDateTime(dt).Value, + + ExcludePropertyPaths = new[] { "**System/*" }, + PropertyPathFilter = (IEnumerable paths) => paths.Where(p => !p.Contains("System")), + IgnoreArrayElementOrder = true, + IgnoreCase = true, + IgnoreLineEndingDifferences = true, + IgnoreWhiteSpaceDifferences = true, +}); +``` + +Some options in the `JsonDeepEqualDiffOptions` class only apply to JSON serialization for object comparisons: + +- ExcludePropertyNames - Property names to ignore in the equality comparison. The names can contain glob-style wildcards: `*` to match any characters, `?` to match a single character. +- PropertyFilter - A custom function to choose the `JsonProperty` values that are included in the equality comparison. Use this if you need more control over the filtering than the "ExcludePropertyNames" option provides. +- NullValueHandling - How null values are serialized to JSON. +- DefaultValueHandling - How default values are serialized to JSON (such as `0` for integers, `""` for strings, etc.). +- ReferenceLoopHandling - How to handle circular references during JSON serialization. +- DateFormatString - A custom DateTime format string that specifies how `DateTime` and `DateTimeOffset` values are serialized +- DateTimeConverter - A custom function to adjust DateTime and DateTimeOffset values before serialization. This can be used to control how milliseconds are truncated or rounded, for example. +- JsonAttributeHandling - Whether to respect Json.NET attributes like `JsonPropertyAttribute` and `JsonIgnoreAttribute` during JSON serialization. +- JsonConverters - Any custom `JsonConverter` objects to use during JSON serialization. + +Some options in `JsonDiffOptions` class apply to any type of JSON comparison: + +- ExcludePropertyPaths - Property paths to ignore in the equality comparison using [JSON pointer notation](https://tools.ietf.org/html/rfc6901). The paths can contain glob-style wildcards: `*` to match any property name, `**` to match any path, `?` to match a single character in a property name. +- PropertyPathFilter - A custom function to choose the property paths that are included in the equality comparison. Use this if you need more control over the filtering than the "ExcludePropertyPaths" option provides. +- IgnoreArrayElementOrder - Set to True if you want to treat two collections with the same elements in any order as equal. +- IgnoreCase - Set to true if you want to use case-insensitive comparison for all string values. +- IgnoreLineEndingDifferences - Set to true if you want to treat `\r\n`, `\r`, and `\n` as equivalent in all string values. +- IgnoreWhiteSpaceDifferences - Set to true if you want to treat any number of consecutive whitespace characters as equivalent in all string values. + + +Differences +=========== +The main focus of this library is the Assert classes, but there are also methods that will return a list of differences instead of throwing exceptions. + +You can use the `JsonDeepEqualDiff.EnumerateDifferences` static method to compare two objects: + +```c# +var expected = new { Message = "Hello!" }; +var actual = new { Message = "Hello, World!" }; + +IEnumerable differences = JsonDeepEqualDiff.EnumerateDifferences(expected, actual); +Console.WriteLine(string.Join(Environment.NewLine, differences.Take(10))); + +``` + +You can use the static `JsonDiff.EnumerateDifferences` method to compare JSON strings or JToken values directly: + +```c# +var expectedJson = @"{ ""a"":1 }"; +var actualJson = @"{ ""a"":2 }"; + +IEnumerable differences = JsonDiff.EnumerateDifferences(expectedJson, actualJson); +Console.WriteLine(string.Join(Environment.NewLine, differences.Take(10))); +``` + +These Diff methods all support the same options as the Assert method equivalents. diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..edeed79 --- /dev/null +++ b/UNLICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..e1edaf5 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,13 @@ +skip_tags: true +image: Visual Studio 2019 +configuration: Release +platform: Any CPU +before_build: +- cmd: nuget restore +build: + verbosity: minimal +test_script: +- cmd: dotnet test +artifacts: +- path: JsonDeepEqual\bin\Release\*.nupkg +- path: JsonDeepEqual\bin\Release\netstandard2.0\*.* \ No newline at end of file diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 0000000..f19d916 --- /dev/null +++ b/stylecop.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "orderingRules": { + "systemUsingDirectivesFirst": false, + "usingDirectivesPlacement": "outsideNamespace" + }, + "documentationRules": { + "documentInternalElements": false, + "documentPrivateElements": false, + "documentPrivateFields": false + } + } +} \ No newline at end of file