├── .config
└── dotnet-tools.json
├── .github
├── CODE_OF_CONDUCT.md
└── workflows
│ └── build.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── Diffract.sln
├── LICENSE
├── README.md
├── diffract-64x64.png
├── diffract.png
├── global.json
├── paket.dependencies
├── paket.lock
├── src
└── Diffract
│ ├── CustomDiffer.fs
│ ├── DictionaryShape.fs
│ ├── DiffPrinter.fs
│ ├── Differ.fs
│ ├── Diffract.fs
│ ├── Diffract.fsproj
│ ├── Extensions.fs
│ ├── ReadOnlyDictionaryShape.fs
│ ├── Types.fs
│ ├── paket.references
│ └── paket.template
└── tests
├── Diffract.CSharp.Tests
├── CustomDiffers.cs
├── Diffract.CSharp.Tests.csproj
├── Tests.cs
└── paket.references
├── Diffract.Tests
├── Diffract.Tests.fsproj
├── Program.fs
├── Tests.fs
└── paket.references
└── Test.fsx
/.config/dotnet-tools.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "isRoot": true,
4 | "tools": {
5 | "paket": {
6 | "version": "8.0.3",
7 | "commands": [
8 | "paket"
9 | ]
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributor Covenant Code of Conduct
3 |
4 | ## Our Pledge
5 |
6 | We as members, contributors, and leaders pledge to make participation in our
7 | community a harassment-free experience for everyone, regardless of age, body
8 | size, visible or invisible disability, ethnicity, sex characteristics, gender
9 | identity and expression, level of experience, education, socio-economic status,
10 | nationality, personal appearance, race, caste, color, religion, or sexual identity
11 | and orientation.
12 |
13 | We pledge to act and interact in ways that contribute to an open, welcoming,
14 | diverse, inclusive, and healthy community.
15 |
16 | ## Our Standards
17 |
18 | Examples of behavior that contributes to a positive environment for our
19 | community include:
20 |
21 | * Demonstrating empathy and kindness toward other people
22 | * Being respectful of differing opinions, viewpoints, and experiences
23 | * Giving and gracefully accepting constructive feedback
24 | * Accepting responsibility and apologizing to those affected by our mistakes,
25 | and learning from the experience
26 | * Focusing on what is best not just for us as individuals, but for the
27 | overall community
28 |
29 | Examples of unacceptable behavior include:
30 |
31 | * The use of sexualized language or imagery, and sexual attention or
32 | advances of any kind
33 | * Trolling, insulting or derogatory comments, and personal or political attacks
34 | * Public or private harassment
35 | * Publishing others' private information, such as a physical or email
36 | address, without their explicit permission
37 | * Other conduct which could reasonably be considered inappropriate in a
38 | professional setting
39 |
40 | ## Enforcement Responsibilities
41 |
42 | Community leaders are responsible for clarifying and enforcing our standards of
43 | acceptable behavior and will take appropriate and fair corrective action in
44 | response to any behavior that they deem inappropriate, threatening, offensive,
45 | or harmful.
46 |
47 | Community leaders have the right and responsibility to remove, edit, or reject
48 | comments, commits, code, wiki edits, issues, and other contributions that are
49 | not aligned to this Code of Conduct, and will communicate reasons for moderation
50 | decisions when appropriate.
51 |
52 | ## Scope
53 |
54 | This Code of Conduct applies within all community spaces, and also applies when
55 | an individual is officially representing the community in public spaces.
56 | Examples of representing our community include using an official e-mail address,
57 | posting via an official social media account, or acting as an appointed
58 | representative at an online or offline event.
59 |
60 | ## Enforcement
61 |
62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
63 | reported to the community leaders responsible for enforcement at softwarecraft@d-edge.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
122 |
123 | For answers to common questions about this code of conduct, see the FAQ at
124 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
125 |
126 | [homepage]: https://www.contributor-covenant.org
127 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
128 | [Mozilla CoC]: https://github.com/mozilla/diversity
129 | [FAQ]: https://www.contributor-covenant.org/faq
130 | [translations]: https://www.contributor-covenant.org/translations
131 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: .NET Core
2 | on:
3 | push:
4 | pull_request:
5 | release:
6 | types:
7 | - published
8 | env:
9 | # Stop wasting time caching packages
10 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
11 | # Disable sending usage data to Microsoft
12 | DOTNET_CLI_TELEMETRY_OPTOUT: true
13 | # Project name to pack and publish
14 | PROJECT_NAME: Diffract
15 | # GitHub Packages Feed settings
16 | GITHUB_FEED: https://nuget.pkg.github.com/d-edge/
17 | GITHUB_USER: tarmil
18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19 | # Official NuGet Feed settings
20 | NUGET_FEED: https://api.nuget.org/v3/index.json
21 | NUGET_KEY: ${{ secrets.NUGET_KEY }}
22 | jobs:
23 | build:
24 | runs-on: ${{ matrix.os }}
25 | strategy:
26 | matrix:
27 | os: [ ubuntu-latest, windows-latest, macos-latest ]
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v2
31 | - name: Setup .NET Core
32 | uses: actions/setup-dotnet@v1
33 | with:
34 | dotnet-version: 8.0.100
35 | - name: Paket restore
36 | run: |
37 | dotnet tool restore
38 | dotnet paket install
39 | - name: Restore
40 | run: dotnet restore
41 | - name: Build
42 | run: dotnet build -c Release --no-restore
43 | - name: Test
44 | run: dotnet test -c Release
45 | - name: Strip HTML from README
46 | uses: Tarmil/strip-markdown-html@v0.3
47 | with:
48 | input-path: README.md
49 | output-path: src/Diffract/README.md
50 | - name: Pack
51 | if: matrix.os == 'ubuntu-latest'
52 | run: |
53 | if [ "$GITHUB_REF_TYPE" = "tag" ]; then
54 | arrTag=(${GITHUB_REF//\// })
55 | VERSION="${arrTag[2]}"
56 | echo Version: $VERSION
57 | VERSION="${VERSION//v}"
58 | echo Clean Version: $VERSION
59 | else
60 | git fetch --prune --unshallow --tags --quiet
61 | latestTag=$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.1)
62 | runId=$GITHUB_RUN_ID
63 | VERSION="${latestTag//v}-build.${runId}"
64 | echo Non-release version: $VERSION
65 | fi
66 | dotnet paket pack --symbols --version $VERSION nupkg
67 | - name: Upload Artifact
68 | if: matrix.os == 'ubuntu-latest'
69 | uses: actions/upload-artifact@v4
70 | with:
71 | name: nupkg
72 | path: nupkg
73 | prerelease:
74 | needs: build
75 | if: github.ref == 'refs/heads/main'
76 | runs-on: ubuntu-latest
77 | steps:
78 | - name: Download Artifact
79 | uses: actions/download-artifact@v4
80 | with:
81 | name: nupkg
82 | - name: Push to GitHub Feed
83 | run: |
84 | for f in *.nupkg
85 | do
86 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED
87 | done
88 | deploy:
89 | needs: build
90 | if: github.event_name == 'release'
91 | runs-on: ubuntu-latest
92 | steps:
93 | - uses: actions/checkout@v2
94 | - name: Setup .NET Core
95 | uses: actions/setup-dotnet@v1
96 | with:
97 | dotnet-version: 8.0.100
98 | - name: Download Artifact
99 | uses: actions/download-artifact@v4
100 | with:
101 | name: nupkg
102 | - name: Push to GitHub Feed
103 | run: |
104 | for f in *.nupkg
105 | do
106 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED
107 | done
108 | - name: Push to NuGet Feed
109 | run: dotnet nuget push *.nupkg --source $NUGET_FEED --skip-duplicate --api-key $NUGET_KEY
110 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | bin/
2 | obj/
3 | .vs/
4 | *.user
5 | .idea/
6 | .ionide/
7 | paket-files/
8 | packages/
9 | .fake/
10 | build/
11 | TestResults/
12 | .paket/
13 | src/Diffract/README.md
14 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | softwarecraft@d-edge.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/Diffract.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 16
4 | VisualStudioVersion = 16.0.30114.105
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".paket", ".paket", "{A2D88361-AEDE-4F07-A5F1-7BD5366B59C5}"
7 | ProjectSection(SolutionItems) = preProject
8 | paket.dependencies = paket.dependencies
9 | EndProjectSection
10 | EndProject
11 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84AC3DD1-BF17-49DF-9582-07460FC75D5A}"
12 | EndProject
13 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Diffract", "src\Diffract\Diffract.fsproj", "{FAFE184C-6C5E-47A1-9A93-7B8F48082F49}"
14 | EndProject
15 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{67F4C459-70D9-44D5-9E3F-883FFBDF6018}"
16 | ProjectSection(SolutionItems) = preProject
17 | tests\Test.fsx = tests\Test.fsx
18 | EndProjectSection
19 | EndProject
20 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Diffract.Tests", "tests\Diffract.Tests\Diffract.Tests.fsproj", "{45228A71-E9C9-49ED-8094-8F3354924C10}"
21 | EndProject
22 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diffract.CSharp.Tests", "tests\Diffract.CSharp.Tests\Diffract.CSharp.Tests.csproj", "{6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}"
23 | EndProject
24 | Global
25 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
26 | Debug|Any CPU = Debug|Any CPU
27 | Release|Any CPU = Release|Any CPU
28 | EndGlobalSection
29 | GlobalSection(SolutionProperties) = preSolution
30 | HideSolutionNode = FALSE
31 | EndGlobalSection
32 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
33 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
34 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49}.Debug|Any CPU.Build.0 = Debug|Any CPU
35 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49}.Release|Any CPU.ActiveCfg = Release|Any CPU
36 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49}.Release|Any CPU.Build.0 = Release|Any CPU
37 | {45228A71-E9C9-49ED-8094-8F3354924C10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
38 | {45228A71-E9C9-49ED-8094-8F3354924C10}.Debug|Any CPU.Build.0 = Debug|Any CPU
39 | {45228A71-E9C9-49ED-8094-8F3354924C10}.Release|Any CPU.ActiveCfg = Release|Any CPU
40 | {45228A71-E9C9-49ED-8094-8F3354924C10}.Release|Any CPU.Build.0 = Release|Any CPU
41 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
42 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}.Debug|Any CPU.Build.0 = Debug|Any CPU
43 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}.Release|Any CPU.ActiveCfg = Release|Any CPU
44 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658}.Release|Any CPU.Build.0 = Release|Any CPU
45 | EndGlobalSection
46 | GlobalSection(NestedProjects) = preSolution
47 | {FAFE184C-6C5E-47A1-9A93-7B8F48082F49} = {84AC3DD1-BF17-49DF-9582-07460FC75D5A}
48 | {45228A71-E9C9-49ED-8094-8F3354924C10} = {67F4C459-70D9-44D5-9E3F-883FFBDF6018}
49 | {6EF4AF3B-8257-4970-8BF8-AC5C21CFB658} = {67F4C459-70D9-44D5-9E3F-883FFBDF6018}
50 | EndGlobalSection
51 | EndGlobal
52 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 D-EDGE
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Diffract is a .NET library that displays a readable diff between two objects. It is particularly useful for unit testing complex objects. Diffract is maintained by folks at [D-EDGE](https://www.d-edge.com/).
17 |
18 | Here is an example:
19 |
20 | ```csharp
21 | using DEdge.Diffract;
22 |
23 | record User(string Name, int Age, string[] Pets);
24 |
25 | var expected = new User("Emma", 42, new[] { "Oscar", "Fluffy", "Tibbles" });
26 | var actual = new User("Andy", 42, new[] { "Oscar", "Sparky" });
27 |
28 | Differ.Assert(expected, actual);
29 | ```
30 |
31 | The above throws an `AssertionFailedException` with the following message:
32 |
33 | ```
34 | Value differs by 2 fields:
35 | Name Expect = "Emma"
36 | Actual = "Andy"
37 | Pets collection differs:
38 | Pets.Count Expect = 3
39 | Actual = 2
40 | Pets[1] Expect = "Fluffy"
41 | Actual = "Sparky"
42 | ```
43 |
44 | Diffract can drill down many composite types:
45 | * POCOs;
46 | * C# records;
47 | * F# records and anonymous records;
48 | * F# unions;
49 | * enumerables (`IEnumerable`);
50 | * dictionaries (`IDictionary`, `IReadOnlyDictionary`);
51 | * value and reference tuples.
52 |
53 | Values of any other equatable type (like `string` and `int` in the above example) are treated as leaves that can be tested for equality.
54 |
55 | ## Example outputs
56 |
57 | POCO or record with multiple field differences:
58 |
59 | ```csharp
60 | record User(string Name, int Age, bool IsActive);
61 |
62 | var expected = new User("Emma", 42, true);
63 | var actual = new User("Andy", 35, true);
64 | ```
65 |
66 | ```
67 | Value differs by 2 fields:
68 | Name Expect = "Emma"
69 | Actual = "Andy"
70 | Age Expect = 42
71 | Actual = 35
72 | ```
73 |
74 | F# union where the case is different:
75 |
76 | ```fsharp
77 | type Contact =
78 | | Email of address: string
79 | | Phone of number: string
80 |
81 | let expected = Email "user@example.com"
82 | let actual = Phone "555-123-456"
83 | ```
84 |
85 | ```
86 | Value differs by union case:
87 | Expect is Email
88 | Actual is Phone
89 | ```
90 |
91 | F# union where the case is the same and the value is different:
92 |
93 | ```fsharp
94 | type Contact =
95 | | Email of address: string
96 | | Phone of number: string
97 |
98 | let expected = Email "user@example.com"
99 | let actual = Email "someone@example.com"
100 | ```
101 |
102 | ```
103 | Value differs by union case Email fields:
104 | address Expect = "user@example.com"
105 | Actual = "someone@example.com"
106 | ```
107 |
108 | Enumerables show the counts if they differ, followed by the diffs per item:
109 |
110 | ```csharp
111 | var expected = new string[] { "first", "second" };
112 | var actual = new string[] { "first", "2nd", "third" };
113 | ```
114 |
115 | ```
116 | Value collection differs:
117 | Count Expect = 2
118 | Actual = 3
119 | [1] Expect = "second"
120 | Actual = "2nd"
121 | ```
122 |
123 | Dictionaries show the keys missing on either side, followed by the diffs per item that exists in both:
124 |
125 | ```csharp
126 | var expected = new Dictionary
127 | {
128 | { "first", 1 },
129 | { "second", 2 },
130 | { "third", 3 },
131 | };
132 | var actual = new Dictionary
133 | {
134 | { "first", 1 },
135 | { "third", 2 },
136 | { "fourth", 4 },
137 | };
138 | ```
139 |
140 | ```
141 | Value dictionary differs:
142 | ["second"] Actual is missing
143 | ["fourth"] Expect is missing
144 | ["third"] Expect = 3
145 | Actual = 2
146 | ```
147 |
148 | ## API
149 |
150 | Diffract lives in the namespace `DEdge.Diffract`. Its main API is the class `Differ`, which provides the following methods:
151 |
152 | ```csharp
153 | void Assert(T expected, T actual, IDiffer differ = null, PrintParams param = null)
154 | ```
155 |
156 | Computes the diff between two objects and, if it is not empty, throws an `AssertionFailedException` with the diff as message.
157 |
158 | ```csharp
159 | string ToString(T expected, T actual, IDiffer differ = null, PrintParams param = null)
160 | ```
161 |
162 | Prints the diff between two objects to a string.
163 |
164 | ```csharp
165 | void Write(T expected, T actual, TextWriter writer = null, IDiffer differ = null, PrintParams param = null)
166 | ```
167 |
168 | Prints the diff between two objects to the given TextWriter (or to standard output if not provided).
169 |
170 | ```csharp
171 | FSharpOption Diff(T expected, T actual, IDiffer differ = null)
172 | ```
173 |
174 | Computes the diff between two objects. Returns `None` if the objects are found to be equal.
175 |
--------------------------------------------------------------------------------
/diffract-64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d-edge/Diffract/c9288ab13d15910bdc413f9b59c9c7cc715e00c7/diffract-64x64.png
--------------------------------------------------------------------------------
/diffract.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d-edge/Diffract/c9288ab13d15910bdc413f9b59c9c7cc715e00c7/diffract.png
--------------------------------------------------------------------------------
/global.json:
--------------------------------------------------------------------------------
1 | {
2 | "sdk": {
3 | "version": "8.0.100",
4 | "allowPrerelease": true,
5 | "rollForward": "major"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/paket.dependencies:
--------------------------------------------------------------------------------
1 | source https://api.nuget.org/v3/index.json
2 | storage: none
3 | framework: netstandard2.0
4 | strategy: min
5 | lowest_matching: true
6 |
7 | nuget FSharp.Core >= 5.0.0
8 | nuget TypeShape >= 10.0.0
9 |
10 | group test
11 | source https://api.nuget.org/v3/index.json
12 | storage: none
13 | framework: net8.0
14 |
15 | nuget FsCheck
16 | nuget FsCheck.Xunit
17 | nuget Microsoft.NET.Test.Sdk
18 | nuget xunit
19 | nuget xunit.runner.visualstudio
20 |
--------------------------------------------------------------------------------
/paket.lock:
--------------------------------------------------------------------------------
1 | STORAGE: NONE
2 | STRATEGY: MIN
3 | LOWEST_MATCHING: TRUE
4 | RESTRICTION: == netstandard2.0
5 | NUGET
6 | remote: https://api.nuget.org/v3/index.json
7 | FSharp.Core (5.0)
8 | System.Reflection.Emit.ILGeneration (4.7)
9 | System.Reflection.Emit.Lightweight (4.7)
10 | System.Reflection.Emit.ILGeneration (>= 4.7)
11 | TypeShape (10.0)
12 | FSharp.Core (>= 4.5.4)
13 | System.Reflection.Emit.Lightweight (>= 4.7)
14 |
15 | GROUP test
16 | STORAGE: NONE
17 | RESTRICTION: == net8.0
18 | NUGET
19 | remote: https://api.nuget.org/v3/index.json
20 | FsCheck (2.16.6)
21 | FSharp.Core (>= 4.2.3)
22 | FsCheck.Xunit (2.16.6)
23 | FsCheck (2.16.6)
24 | xunit.extensibility.execution (>= 2.2 < 3.0)
25 | FSharp.Core (8.0.400)
26 | Microsoft.CodeCoverage (17.11.1)
27 | Microsoft.NET.Test.Sdk (17.11.1)
28 | Microsoft.CodeCoverage (>= 17.11.1)
29 | Microsoft.TestPlatform.TestHost (>= 17.11.1)
30 | Microsoft.TestPlatform.ObjectModel (17.11.1)
31 | System.Reflection.Metadata (>= 1.6)
32 | Microsoft.TestPlatform.TestHost (17.11.1)
33 | Microsoft.TestPlatform.ObjectModel (>= 17.11.1)
34 | Newtonsoft.Json (>= 13.0.1)
35 | Newtonsoft.Json (13.0.3)
36 | System.Collections.Immutable (8.0)
37 | System.Reflection.Metadata (8.0)
38 | System.Collections.Immutable (>= 8.0)
39 | xunit (2.9)
40 | xunit.analyzers (>= 1.15)
41 | xunit.assert (>= 2.9)
42 | xunit.core (2.9)
43 | xunit.abstractions (2.0.3)
44 | xunit.analyzers (1.16)
45 | xunit.assert (2.9)
46 | xunit.core (2.9)
47 | xunit.extensibility.core (2.9)
48 | xunit.extensibility.execution (2.9)
49 | xunit.extensibility.core (2.9)
50 | xunit.abstractions (>= 2.0.3)
51 | xunit.extensibility.execution (2.9)
52 | xunit.extensibility.core (2.9)
53 | xunit.runner.visualstudio (2.8.2)
54 |
--------------------------------------------------------------------------------
/src/Diffract/CustomDiffer.fs:
--------------------------------------------------------------------------------
1 | namespace DEdge.Diffract
2 |
3 | open System
4 | open DEdge.Diffract
5 |
6 | []
7 | type CustomDiffer<'T> =
8 |
9 | ///
10 | /// Create a custom differ for a specific type.
11 | ///
12 | /// Builds the diff function for this type.
13 | static member Build(buildDiffFunction: Func>) =
14 | { new ICustomDiffer with
15 | member this.GetCustomDiffer<'U>(differFactory, shape) =
16 | if shape.Type = typeof<'T> then
17 | let diffFunction = buildDiffFunction.Invoke(differFactory)
18 | { new IDiffer<'T> with
19 | member _.Diff(x1, x2) = diffFunction.Invoke(x1, x2) }
20 | |> unbox>
21 | |> Some
22 | else
23 | None }
24 |
25 | ///
26 | /// Create a custom differ for a specific type.
27 | ///
28 | /// Builds the diff function for this type.
29 | static member Build(buildDiffFunction: IDifferFactory -> 'T -> 'T -> Diff option) =
30 | { new ICustomDiffer with
31 | member this.GetCustomDiffer<'U>(differFactory, shape) =
32 | if shape.Type = typeof<'T> then
33 | let diffFunction = buildDiffFunction differFactory
34 | { new IDiffer<'T> with
35 | member _.Diff(x1, x2) = diffFunction x1 x2 }
36 | |> unbox>
37 | |> Some
38 | else
39 | None }
40 |
41 | ///
42 | /// Create a custom differ for a specific type.
43 | ///
44 | /// The diff function for this type.
45 | static member Build(diffFunction: Func<'T, 'T, Diff option>) =
46 | CustomDiffer.Build(fun _ -> diffFunction)
47 |
48 | ///
49 | /// Create a custom differ for a specific type by mapping it to a diffable type.
50 | ///
51 | /// The mapping function.
52 | /// The type for which a custom differ is being created.
53 | /// The type used to actually perform the diff.
54 | static member Map<'U>(mapFunction: Func<'T, 'U>) =
55 | CustomDiffer<'T>.Build(fun differFactory ->
56 | let differ = differFactory.GetDiffer<'U>()
57 | fun x1 x2 -> differ.Diff(mapFunction.Invoke(x1), mapFunction.Invoke(x2)))
58 |
59 | []
60 | type CustomDiffer =
61 |
62 | ///
63 | /// Create a custom differ for a specific type.
64 | ///
65 | /// Builds the diff function for this type.
66 | static member Build(buildDiffFunction: Func>) =
67 | CustomDiffer<'T>.Build(buildDiffFunction)
68 |
69 | ///
70 | /// Create a custom differ for a specific type.
71 | ///
72 | /// Builds the diff function for this type.
73 | static member Build(buildDiffFunction: IDifferFactory -> 'T -> 'T -> Diff option) =
74 | CustomDiffer<'T>.Build(buildDiffFunction)
75 |
76 | ///
77 | /// Create a custom differ for a specific type.
78 | ///
79 | /// The diff function for this type.
80 | static member Build(diffFunction: Func<'T, 'T, Diff option>) =
81 | CustomDiffer<'T>.Build(diffFunction)
82 |
83 | ///
84 | /// Create a custom differ for a leaf type using default comparison and a custom display format.
85 | ///
86 | /// The display format.
87 | static member Leaf<'T when 'T : equality> (format: Func<'T, string>) =
88 | CustomDiffer.Build<'T>(fun x y ->
89 | if x = y then
90 | None
91 | else
92 | Diff.Value(format.Invoke(x), format.Invoke(y))
93 | |> Some)
94 |
95 | ///
96 | /// Combine multiple custom differs.
97 | ///
98 | /// The custom differs.
99 | static member Combine (differs: seq) =
100 | CombinedCustomDiffer(differs) :> ICustomDiffer
101 |
102 | ///
103 | /// Combine multiple custom differs.
104 | ///
105 | /// The custom differs.
106 | static member Combine ([] differs: ICustomDiffer[]) =
107 | CustomDiffer.Combine(differs :> seq<_>)
108 |
--------------------------------------------------------------------------------
/src/Diffract/DictionaryShape.fs:
--------------------------------------------------------------------------------
1 | namespace DEdge.Diffract.DictionaryShape
2 |
3 | open System
4 | open System.Collections.Generic
5 | open TypeShape.Core
6 |
7 | type IDictionaryVisitor<'R> =
8 | abstract Visit<'Dict, 'K, 'V when 'K : equality and 'Dict :> IDictionary<'K, 'V>> : unit -> 'R
9 |
10 | type IShapeDictionary =
11 | abstract Key : TypeShape
12 | abstract Value : TypeShape
13 | abstract Accept : visitor: IDictionaryVisitor<'R> -> 'R
14 |
15 | type private ShapeDictionary<'Dict, 'K, 'V when 'K : equality and 'Dict :> IDictionary<'K, 'V>>() =
16 | interface IShapeDictionary with
17 | member _.Key = shapeof<'K> :> _
18 | member _.Value = shapeof<'V> :> _
19 | member _.Accept(v) = v.Visit<'Dict, 'K, 'V>()
20 |
21 | module Shape =
22 |
23 | let (|GenericInterface|_|) (fullName: string) (s: TypeShape) =
24 | match s.Type.GetInterface(fullName) with
25 | | null ->
26 | match s.ShapeInfo with
27 | | Generic (td, ta) when td.FullName = fullName -> Some ta
28 | | _ -> None
29 | | iface ->
30 | Some (iface.GetGenericArguments())
31 |
32 | let (|Dictionary|_|) (s: TypeShape) =
33 | match s with
34 | | GenericInterface "System.Collections.Generic.IDictionary`2" ta ->
35 | Activator.CreateInstanceGeneric>(Array.append [|s.Type|] ta)
36 | :?> IShapeDictionary
37 | |> Some
38 | | _ -> None
39 |
--------------------------------------------------------------------------------
/src/Diffract/DiffPrinter.fs:
--------------------------------------------------------------------------------
1 | module DEdge.Diffract.DiffPrinter
2 |
3 | open System.IO
4 |
5 | let toStreamImpl (w: TextWriter) param (d: Diff) =
6 | let originalParam = param
7 | let param = if originalParam.ensureFirstLineIsAligned then { originalParam with ensureFirstLineIsAligned = false } else originalParam
8 |
9 | let addPathField path field = if path = "" then field else (path + "." + field)
10 | let addPathIndex path index = path + "[" + index + "]"
11 | let indentLike str = String.replicate (String.length str) " "
12 | let displayPath path = if path = "" then param.neutralName else path
13 |
14 | let printValue indent path (x1: obj) (x2: obj) =
15 | let dpath = if path = "" then "" else path + " "
16 | if isNull x1 then
17 | w.WriteLine($"%s{indent}%s{dpath}%s{param.x1Name} is null")
18 | w.WriteLine($"%s{indent}%s{indentLike dpath}%s{param.x2Name} = %A{x2}")
19 | elif isNull x2 then
20 | w.WriteLine($"%s{indent}%s{dpath}%s{param.x1Name} = %A{x1}")
21 | w.WriteLine($"%s{indent}%s{indentLike dpath}%s{param.x2Name} is null")
22 | else
23 | w.WriteLine($"%s{indent}%s{dpath}%s{param.x1Name} = %A{x1}")
24 | w.WriteLine($"%s{indent}%s{indentLike dpath}%s{param.x2Name} = %A{x2}")
25 |
26 | let rec loop (indent: string) (path: string) (d: Diff) =
27 | match d with
28 | | Diff.Value (x1, x2) ->
29 | printValue indent path x1 x2
30 | | Diff.Nullness (x1, x2) ->
31 | let dpath = if path = "" then "" else path + " "
32 | let dindent = indentLike dpath
33 | w.WriteLine($"""%s{indent}%s{dpath}%s{param.x1Name} is%s{if isNull x1 then "" else " not"} null""")
34 | w.WriteLine($"""%s{indent}%s{dindent}%s{param.x2Name} is%s{if isNull x2 then "" else " not"} null""")
35 | | Diff.Record fields when fields.Count = 1 ->
36 | loop indent (addPathField path fields.[0].Name) fields.[0].Diff
37 | | Diff.Record fields ->
38 | w.WriteLine($"%s{indent}%s{displayPath path} differs by %i{fields.Count} fields:")
39 | let indent = indent + param.indent
40 | for field in fields do
41 | loop indent (addPathField path field.Name) field.Diff
42 | | Diff.UnionCase (caseName1, caseName2) ->
43 | w.WriteLine($"%s{indent}%s{displayPath path} differs by union case:")
44 | let indent = indent + param.indent
45 | w.WriteLine($"%s{indent}%s{param.x1Name} is %s{caseName1}")
46 | w.WriteLine($"%s{indent}%s{param.x2Name} is %s{caseName2}")
47 | | Diff.UnionField (_case, fields) when fields.Count = 1 ->
48 | loop indent (addPathField path fields.[0].Name) fields.[0].Diff
49 | | Diff.UnionField (case, fields) ->
50 | w.WriteLine($"%s{indent}%s{displayPath path} differs by union case %s{case} fields:")
51 | let indent = indent + param.indent
52 | for field in fields do
53 | loop indent (addPathField path field.Name) field.Diff
54 | | Diff.Collection (c1, c2, diffs) ->
55 | w.WriteLine($"%s{indent}%s{displayPath path} collection differs:")
56 | let indent = indent + param.indent
57 | if c1 <> c2 then
58 | let countPath = addPathField path "Count"
59 | w.WriteLine($"%s{indent}%s{countPath} %s{param.x1Name} = %i{c1}")
60 | w.WriteLine($"%s{indent}%s{indentLike countPath} %s{param.x2Name} = %i{c2}")
61 | for item in diffs do
62 | loop indent (addPathIndex path item.Name) item.Diff
63 | | Diff.Custom cd ->
64 | cd.WriteTo(w, param, indent, path, loop)
65 | | Diff.Dictionary (keysInX1, keysInX2, common) ->
66 | w.WriteLine($"%s{indent}%s{displayPath path} dictionary differs:")
67 | let indent = indent + param.indent
68 | for k in keysInX1 do
69 | w.WriteLine($"%s{indent}%s{param.x2Name}[%s{k}] is missing")
70 | for k in keysInX2 do
71 | w.WriteLine($"%s{indent}%s{param.x1Name}[%s{k}] is missing")
72 | for item in common do
73 | loop indent (addPathIndex path item.Name) item.Diff
74 |
75 | match originalParam.ensureFirstLineIsAligned, d with
76 | | true, Diff.Value (x1, x2) ->
77 | w.WriteLine()
78 | printValue "" "" x1 x2
79 | | true, Diff.Custom cd ->
80 | cd.WriteTo(w, originalParam, "", "", loop)
81 | | _ -> loop "" "" d
82 |
83 | let write (param: PrintParams) (w: TextWriter) (d: Diff option) =
84 | match d with
85 | | None -> w.WriteLine($"No differences between {param.x1Name} and {param.x2Name}.")
86 | | Some d -> toStreamImpl w param d
87 |
88 | let toString param d =
89 | use w = new StringWriter(NewLine = "\n")
90 | write param w d
91 | w.ToString()
92 |
--------------------------------------------------------------------------------
/src/Diffract/Differ.fs:
--------------------------------------------------------------------------------
1 | namespace DEdge.Diffract
2 |
3 | #nowarn "40"
4 |
5 | open System
6 | open System.Collections.Generic
7 | open TypeShape.Core
8 | open DEdge.Diffract.ReadOnlyDictionaryShape
9 | open DEdge.Diffract.DictionaryShape
10 |
11 | module DifferImpl =
12 |
13 | type private Cache = Dictionary
14 | type private CachedDiffer<'T> = Cache -> IDiffer<'T>
15 |
16 | let private checkNull<'T> (differ: IDiffer<'T>) =
17 | if typeof<'T>.IsValueType then
18 | differ
19 | else
20 | { new IDiffer<'T> with
21 | member _.Diff(x1, x2) =
22 | match isNull (box x1), isNull (box x2) with
23 | | false, false -> differ.Diff(x1, x2)
24 | | true, true -> None
25 | | _ -> Some (Nullness(x1, x2)) }
26 |
27 | /// Add to the cache a differ for a type that may be recursive (ie have nested fields of its own type).
28 | let private addRecursiveToCache (differ: CachedDiffer<'T>) : CachedDiffer<'T> =
29 | let ty = typeof<'T>
30 | fun cache ->
31 | match cache.TryGetValue(ty) with
32 | | false, _ ->
33 | let r = ref Unchecked.defaultof>
34 | cache.Add(ty, { new IDifferFactory with
35 | member _.GetDiffer<'U>() =
36 | { new IDiffer<'U> with
37 | member _.Diff(x1, x2) =
38 | (unbox> !r).Diff(x1, x2) } })
39 | let differ = differ cache |> checkNull
40 | r := differ
41 | differ
42 | | true, differFactory ->
43 | differFactory.GetDiffer<'T>()
44 |
45 | /// Add to the cache a differ for a type that cannot be recursive (ie doesn't have fields of arbitrary types).
46 | let private addNonRecursiveToCache (differ: IDiffer<'T>) : CachedDiffer<'T> =
47 | fun cache ->
48 | let ty = typeof<'T>
49 | match cache.TryGetValue(ty) with
50 | | true, differFactory ->
51 | differFactory.GetDiffer<'T>()
52 | | false, _ ->
53 | cache.Add(typeof<'T>, { new IDifferFactory with member _.GetDiffer<'U>() = unbox> differ })
54 | differ
55 |
56 | /// Wrap a simple diffing function as a cached differ.
57 | let private wrap<'Outer, 'Inner> (f: 'Inner -> 'Inner -> Diff option) : CachedDiffer<'Outer> =
58 | { new IDiffer<'Inner> with member _.Diff(x1, x2) = f x1 x2 }
59 | |> unbox>
60 | |> addNonRecursiveToCache
61 |
62 | /// Create a cached differ for a simple type with equality.
63 | let inline private simpleEquality<'Outer, ^Inner when ^Inner : equality> : CachedDiffer<'Outer> =
64 | wrap< ^Outer, ^Inner> (fun x1 x2 -> if x1 = x2 then None else Some (Diff.Value (x1, x2)))
65 |
66 | let inline private failwith (msg: string) = raise (DifferConstructionFailedException(msg))
67 |
68 | /// Create a differ for an arbitrary type.
69 | let rec diffWith<'T> (custom: ICustomDiffer) (cache: Cache) : IDiffer<'T> =
70 | let getCached = { new IDifferFactory with
71 | member _.GetDiffer<'U>() =
72 | match cache.TryGetValue(typeof<'U>) with
73 | | true, d -> d.GetDiffer<'U>()
74 | | false, _ -> diffWith<'U> custom cache }
75 | let (|Custom|_|) shape = custom.GetCustomDiffer(getCached, shape)
76 | match shapeof<'T> with
77 | | Custom d -> d
78 | | Shape.Unit -> wrap<'T, unit> (fun () () -> None) cache
79 | | Shape.Bool -> simpleEquality<'T, bool> cache
80 | | Shape.Byte -> simpleEquality<'T, byte> cache
81 | | Shape.SByte -> simpleEquality<'T, sbyte> cache
82 | | Shape.Int16 -> simpleEquality<'T, int16> cache
83 | | Shape.UInt16 -> simpleEquality<'T, uint16> cache
84 | | Shape.Int32 -> simpleEquality<'T, int32> cache
85 | | Shape.UInt32 -> simpleEquality<'T, uint32> cache
86 | | Shape.Int64 -> simpleEquality<'T, int64> cache
87 | | Shape.UInt64 -> simpleEquality<'T, uint64> cache
88 | | Shape.Single -> simpleEquality<'T, single> cache
89 | | Shape.Double -> simpleEquality<'T, double> cache
90 | | Shape.Decimal -> simpleEquality<'T, decimal> cache
91 | | Shape.String -> simpleEquality<'T, string> cache
92 | | Shape.Char -> simpleEquality<'T, char> cache
93 | | Shape.Guid -> simpleEquality<'T, Guid> cache
94 | | Shape.DateTime -> simpleEquality<'T, DateTime> cache
95 | | Shape.DateTimeOffset -> simpleEquality<'T, DateTimeOffset> cache
96 | | Shape.TimeSpan -> simpleEquality<'T, TimeSpan> cache
97 | | Shape.BigInt -> simpleEquality<'T, bigint> cache
98 | | Shape.Uri -> simpleEquality<'T, Uri> cache
99 | | Shape.Tuple (:? ShapeTuple<'T> as t) -> addRecursiveToCache (diffFields custom t.Elements Diff.Record) cache
100 | | Shape.ReadOnlyDictionary d -> addRecursiveToCache (diffReadOnlyDict custom d) cache
101 | | Shape.Dictionary d -> addRecursiveToCache (diffDict custom d) cache
102 | | Shape.Enumerable e -> addRecursiveToCache (diffEnumerable custom e) cache
103 | | Shape.FSharpRecord (:? ShapeFSharpRecord<'T> as r) -> addRecursiveToCache (diffFields custom r.Fields Diff.Record) cache
104 | | Shape.FSharpUnion (:? ShapeFSharpUnion<'T> as u) ->
105 | let tagNames = u.UnionCases |> Array.map (fun c -> c.CaseInfo.Name)
106 | let tagDiffs = u.UnionCases |> Array.map (fun c ->
107 | diffFields custom c.Fields (fun d -> Diff.UnionField (c.CaseInfo.Name, d)) cache)
108 | wrap<'T, 'T> (fun x1 x2 ->
109 | let t1 = u.GetTag x1
110 | let t2 = u.GetTag x2
111 | if t1 = t2 then
112 | tagDiffs.[t1].Diff(x1, x2)
113 | else
114 | Some (Diff.UnionCase (tagNames.[t1], tagNames.[t2])))
115 | cache
116 | | Shape.Enum e ->
117 | let differ =
118 | { new IEnumVisitor> with
119 | member _.Visit<'Enum, 'Underlying when 'Enum : enum<'Underlying> and 'Enum : struct and 'Enum :> ValueType and 'Enum : (new : unit -> 'Enum)>() =
120 | wrap<'T, 'Enum> (fun x1 x2 ->
121 | // Can't do better without 'Enum : equality? :(
122 | if (x1 :> obj).Equals(x2) then None else Some (Diff.Value (x1, x2)))
123 | cache }
124 | |> e.Accept
125 | addNonRecursiveToCache differ cache
126 | | Shape.Poco (:? ShapePoco<'T> as p) ->
127 | let members = p.Properties |> Array.filter (fun p -> p.IsPublic)
128 | addRecursiveToCache (diffReadOnlyFields<'T> custom members Diff.Record) cache
129 | | Shape.Equality e ->
130 | { new IEqualityVisitor> with
131 | member _.Visit<'Actual when 'Actual : equality>() =
132 | wrap<'T, 'Actual> (fun x1 x2 ->
133 | if x1 = x2 then None else Some (Diff.Value (x1, x2)))
134 | cache }
135 | |> e.Accept
136 | | _ -> failwith $"Don't know how to diff values of type {typeof<'T>.AssemblyQualifiedName}"
137 |
138 | /// Create a differ for a type composed of a number of read-only fields.
139 | and diffReadOnlyFields<'T> (custom: ICustomDiffer) (members: IShapeReadOnlyMember<'T>[]) (wrapFieldDiffs: IReadOnlyList -> Diff) (cache: Cache) : IDiffer<'T> =
140 | let fields =
141 | members
142 | |> Array.map (fun f ->
143 | { new IReadOnlyMemberVisitor<'T, 'T -> 'T -> FieldDiff option> with
144 | member _.Visit<'Field>(shape) =
145 | let fieldDiff = diffWith<'Field> custom cache
146 | let name = shape.MemberInfo.Name
147 | fun x1 x2 ->
148 | fieldDiff.Diff(shape.Get x1, shape.Get x2)
149 | |> Option.map (fun diff -> { Name = name; Diff = diff }) }
150 | |> f.Accept)
151 | { new IDiffer<'T> with
152 | member _.Diff(x1, x2) =
153 | match fields |> Seq.choose (fun f -> f x1 x2) |> List.ofSeq with
154 | | [] -> None
155 | | diffs -> Some (wrapFieldDiffs diffs) }
156 |
157 | /// Create a differ for a type composed of a number of read-only or mutable fields.
158 | and diffFields<'T> (custom: ICustomDiffer) (members: IShapeMember<'T>[]) (wrapFieldDiffs: IReadOnlyList -> Diff) (cache: Cache) : IDiffer<'T> =
159 | diffReadOnlyFields custom (unbox[]> members) wrapFieldDiffs cache
160 |
161 | /// Create a differ for a type that implements IEnumerable.
162 | and diffEnumerable<'T> (custom: ICustomDiffer) (e: IShapeEnumerable) (cache: Cache) : IDiffer<'T> =
163 | { new IEnumerableVisitor> with
164 | member _.Visit<'Enum, 'Elt when 'Enum :> seq<'Elt>>() =
165 | let diffItem = diffWith<'Elt> custom cache
166 | { new IDiffer<'Enum> with
167 | member _.Diff(s1, s2) =
168 | let s1 = Seq.cache s1
169 | let s2 = Seq.cache s2
170 | let l1 = Seq.length s1
171 | let l2 = Seq.length s2
172 | match
173 | (s1, s2)
174 | ||> Seq.mapi2 (fun i e1 e2 ->
175 | diffItem.Diff(e1, e2)
176 | |> Option.map (fun diff -> { Name = string i; Diff = diff }))
177 | |> Seq.choose id
178 | |> List.ofSeq
179 | with
180 | | [] when l1 = l2 -> None
181 | | diffs -> Some (Diff.Collection (l1, l2, diffs)) }
182 | |> unbox> }
183 | |> e.Accept
184 |
185 | /// Create a differ for a type that implements IReadOnlyDictionary.
186 | and diffReadOnlyDict<'T> (custom: ICustomDiffer) (d: IShapeReadOnlyDictionary) (cache: Cache) : IDiffer<'T> =
187 | { new IReadOnlyDictionaryVisitor> with
188 | member _.Visit<'Dict, 'K, 'V when 'K : equality and 'Dict :> IReadOnlyDictionary<'K, 'V>>() =
189 | let diffItem = diffWith<'V> custom cache
190 | { new IDiffer<'Dict> with
191 | member _.Diff(d1, d2) =
192 | let seen = HashSet<'K>()
193 | let struct (keysInX1, common) =
194 | (struct ([], []), d1)
195 | ||> Seq.fold (fun (struct (keysInX1, common) as state) (KeyValue (k, v1)) ->
196 | seen.Add(k) |> ignore
197 | match d2.TryGetValue(k) with
198 | | true, v2 ->
199 | match diffItem.Diff(v1, v2) with
200 | | Some d -> struct (keysInX1, { Name = string k; Diff = d } :: common)
201 | | None -> state
202 | | false, _ -> struct (string k :: keysInX1, common))
203 | let keysInX2 =
204 | d2
205 | |> Seq.choose (fun (KeyValue (k, _)) ->
206 | if seen.Contains(k) then None else Some (string k))
207 | |> List.ofSeq
208 | match keysInX1, keysInX2, common with
209 | | [], [], [] -> None
210 | | _ -> Some (Diff.Dictionary (keysInX1, keysInX2, common)) }
211 | |> unbox> }
212 | |> d.Accept
213 |
214 | /// Create a differ for a type that implements IDictionary.
215 | and diffDict<'T> (custom: ICustomDiffer) (d: IShapeDictionary) (cache: Cache) : IDiffer<'T> =
216 | { new IDictionaryVisitor> with
217 | member _.Visit<'Dict, 'K, 'V when 'K : equality and 'Dict :> IDictionary<'K, 'V>>() =
218 | let diffItem = diffWith<'V> custom cache
219 | { new IDiffer<'Dict> with
220 | member _.Diff(d1, d2) =
221 | let seen = HashSet<'K>()
222 | let struct (keysInX1, common) =
223 | (struct ([], []), d1)
224 | ||> Seq.fold (fun (struct (keysInX1, common) as state) (KeyValue (k, v1)) ->
225 | seen.Add(k) |> ignore
226 | match d2.TryGetValue(k) with
227 | | true, v2 ->
228 | match diffItem.Diff(v1, v2) with
229 | | Some d -> struct (keysInX1, { Name = string k; Diff = d } :: common)
230 | | None -> state
231 | | false, _ -> struct (string k :: keysInX1, common))
232 | let keysInX2 =
233 | d2
234 | |> Seq.choose (fun (KeyValue (k, _)) ->
235 | if seen.Contains(k) then None else Some (string k))
236 | |> List.ofSeq
237 | match keysInX1, keysInX2, common with
238 | | [], [], [] -> None
239 | | _ -> Some (Diff.Dictionary (keysInX1, keysInX2, common)) }
240 | |> unbox> }
241 | |> d.Accept
242 |
243 | /// Get a differ for an arbitrary type, without any custom differs.
244 | let simple<'T> = diffWith<'T> (NoCustomDiffer()) (Dictionary())
245 |
--------------------------------------------------------------------------------
/src/Diffract/Diffract.fs:
--------------------------------------------------------------------------------
1 | namespace DEdge.Diffract
2 |
3 | open System
4 | open System.Collections.Generic
5 | open System.IO
6 | open System.Runtime.InteropServices
7 |
8 | []
9 | type Differ private () =
10 |
11 | static let simplePrintParams : PrintParams =
12 | {
13 | indent = " "
14 | x1Name = "Expect"
15 | x2Name = "Actual"
16 | neutralName = "Value"
17 | ensureFirstLineIsAligned = false
18 | }
19 |
20 | static let assertPrintParams : PrintParams =
21 | { simplePrintParams with ensureFirstLineIsAligned = true }
22 |
23 | static let orIfNull (def: 'a) (value: 'a) : 'a when 'a : not struct =
24 | if obj.ReferenceEquals(value, null) then def else value
25 |
26 | static let defaultDiffer d =
27 | if obj.ReferenceEquals(d, null) then DifferImpl.simple else d
28 |
29 | /// The default print parameters for simple printing.
30 | static member SimplePrintParams = simplePrintParams
31 |
32 | /// The default print parameters for assertions.
33 | static member AssertPrintParams = assertPrintParams
34 |
35 | /// Get a differ for a specific type.
36 | /// Custom differs to handle specific types.
37 | static member GetDiffer<'T>([] customDiffers: ICustomDiffer[]) =
38 | match customDiffers with
39 | | null | [||] -> DifferImpl.simple<'T>
40 | | [| customDiffer |] -> DifferImpl.diffWith<'T> customDiffer (Dictionary())
41 | | _ -> DifferImpl.diffWith<'T> (CombinedCustomDiffer(customDiffers)) (Dictionary())
42 |
43 | /// Compute the diff between two values.
44 | /// The first value to diff.
45 | /// The second value to dif.
46 | /// The differ to use. If null, use GetDiffer<T>().
47 | /// The diff between the two objects, or None if they are found equal.
48 | static member Diff<'T>(expected: 'T, actual: 'T, [] differ: IDiffer<'T>) =
49 | let differ = defaultDiffer differ
50 | differ.Diff(expected, actual)
51 |
52 | /// Throw if a diff is non-empty.
53 | /// The diff to check.
54 | /// The printing parameters used to generate the exception message.
55 | static member Assert(diff: Diff option, [] param: PrintParams) =
56 | let param = param |> orIfNull assertPrintParams
57 | if Option.isSome diff then
58 | DiffPrinter.toString param diff
59 | |> AssertionFailedException
60 | |> raise
61 |
62 | /// Throw if a diff is non-empty.
63 | /// The first value to diff.
64 | /// The second value to dif.
65 | /// The differ to use. If null, use GetDiffer<T>().
66 | /// The printing parameters used to generate the exception message.
67 | static member Assert<'T>(expected: 'T, actual: 'T, [] differ: IDiffer<'T>, [] param: PrintParams) =
68 | let diff = Differ.Diff(expected, actual, differ)
69 | Differ.Assert(diff, param)
70 |
71 | /// Print a diff to a string.
72 | /// The diff to print.
73 | /// The printing parameters.
74 | static member ToString(diff: Diff option, [] param: PrintParams) =
75 | let param = param |> orIfNull simplePrintParams
76 | DiffPrinter.toString param diff
77 |
78 | /// Print a diff to a string.
79 | /// The first value to diff.
80 | /// The second value to dif.
81 | /// The differ to use. If null, use GetDiffer<T>().
82 | /// The printing parameters.
83 | static member ToString<'T>(expected: 'T, actual: 'T, [] differ: IDiffer<'T>, [] param: PrintParams) =
84 | let diff = Differ.Diff(expected, actual, differ)
85 | Differ.ToString(diff, param)
86 |
87 | /// Print a diff to a TextWriter.
88 | /// The diff to print.
89 | /// The writer to print to. If null, use standard output.
90 | /// The printing parameters.
91 | static member Write(diff: Diff option, [] writer: TextWriter, [] param: PrintParams) =
92 | let writer = writer |> orIfNull stdout
93 | let param = param |> orIfNull simplePrintParams
94 | DiffPrinter.write param writer diff
95 |
96 | /// Print a diff to a TextWriter.
97 | /// The first value to diff.
98 | /// The second value to dif.
99 | /// The differ to use. If null, use GetDiffer<T>().
100 | /// The writer to print to. If null, use standard output.
101 | /// The printing parameters.
102 | static member Write<'T>(expected: 'T, actual: 'T, [] writer: TextWriter, [] differ: IDiffer<'T>, [] param: PrintParams) =
103 | let diff = Differ.Diff(expected, actual, differ)
104 | Differ.Write(diff, writer, param)
105 |
--------------------------------------------------------------------------------
/src/Diffract/Diffract.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | netstandard2.0
5 | true
6 | latest
7 | true
8 | https://github.com/d-edge/diffract
9 | DEdge.Diffract
10 |
11 |
12 | true
13 | true
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/Diffract/Extensions.fs:
--------------------------------------------------------------------------------
1 | namespace DEdge.Diffract
2 |
3 | open System.Collections.Generic
4 | open System.IO
5 | open System.Runtime.CompilerServices
6 | open System.Runtime.InteropServices
7 |
8 | []
9 | type Extensions private () =
10 |
11 | /// Throw if a diff is non-empty.
12 | /// The differ to use.
13 | /// The first value to diff.
14 | /// The second value to dif.
15 | /// The printing parameters used to generate the exception message.
16 | []
17 | static member Assert(differ: IDiffer<'T>, expected: 'T, actual: 'T, [] param: PrintParams) =
18 | Differ.Assert(expected, actual, differ, param)
19 |
20 | /// Print a diff to a string.
21 | /// The differ to use.
22 | /// The first value to diff.
23 | /// The second value to dif.
24 | /// The printing parameters.
25 | []
26 | static member ToString(differ: IDiffer<'T>, expected: 'T, actual: 'T, [] param: PrintParams) =
27 | Differ.ToString(expected, actual, differ, param)
28 |
29 | /// Print a diff to a TextWriter.
30 | /// The differ to use.
31 | /// The first value to diff.
32 | /// The second value to dif.
33 | /// The writer to print to. If null, use standard output.
34 | /// The printing parameters.
35 | []
36 | static member Write(differ: IDiffer<'T>, expected: 'T, actual: 'T, [] writer: TextWriter, [] param: PrintParams) =
37 | Differ.Write(expected, actual, writer, differ, param)
38 |
39 | /// Get a differ with support for specific types.
40 | /// A custom differ to handle specific types.
41 | []
42 | static member GetDiffer<'T>(custom: ICustomDiffer) =
43 | DifferImpl.diffWith<'T> custom (Dictionary())
44 |
45 | /// Use in a custom differ to cast a differ for one type into a differ for another type.
46 | /// TypeShape documentation
47 | []
48 | static member Unwrap<'T, 'U>(differ: IDiffer<'T>) =
49 | tryUnbox> differ
50 |
--------------------------------------------------------------------------------
/src/Diffract/ReadOnlyDictionaryShape.fs:
--------------------------------------------------------------------------------
1 | namespace DEdge.Diffract.ReadOnlyDictionaryShape
2 |
3 | open System
4 | open System.Collections.Generic
5 | open TypeShape.Core
6 |
7 | type IReadOnlyDictionaryVisitor<'R> =
8 | abstract Visit<'Dict, 'K, 'V when 'K : equality and 'Dict :> IReadOnlyDictionary<'K, 'V>> : unit -> 'R
9 |
10 | type IShapeReadOnlyDictionary =
11 | abstract Key : TypeShape
12 | abstract Value : TypeShape
13 | abstract Accept : visitor: IReadOnlyDictionaryVisitor<'R> -> 'R
14 |
15 | type private ShapeReadOnlyDictionary<'Dict, 'K, 'V when 'K : equality and 'Dict :> IReadOnlyDictionary<'K, 'V>>() =
16 | interface IShapeReadOnlyDictionary with
17 | member _.Key = shapeof<'K> :> _
18 | member _.Value = shapeof<'V> :> _
19 | member _.Accept(v) = v.Visit<'Dict, 'K, 'V>()
20 |
21 | module Shape =
22 |
23 | let (|GenericInterface|_|) (fullName: string) (s: TypeShape) =
24 | match s.Type.GetInterface(fullName) with
25 | | null ->
26 | match s.ShapeInfo with
27 | | Generic (td, ta) when td.FullName = fullName -> Some ta
28 | | _ -> None
29 | | iface ->
30 | Some (iface.GetGenericArguments())
31 |
32 | let (|ReadOnlyDictionary|_|) (s: TypeShape) =
33 | match s with
34 | | GenericInterface "System.Collections.Generic.IReadOnlyDictionary`2" ta ->
35 | Activator.CreateInstanceGeneric>(Array.append [|s.Type|] ta)
36 | :?> IShapeReadOnlyDictionary
37 | |> Some
38 | | _ -> None
39 |
--------------------------------------------------------------------------------
/src/Diffract/Types.fs:
--------------------------------------------------------------------------------
1 | namespace DEdge.Diffract
2 |
3 | open System.Collections.Generic
4 | open System.IO
5 | open System.Runtime.InteropServices
6 | open TypeShape.Core
7 |
8 | /// A computed diff between two objects.
9 | type Diff =
10 | /// The objects are leaf values and are different.
11 | | Value of x1: obj * x2: obj
12 | /// One of the objects is null and the other isn't.
13 | | Nullness of x1: obj * x2: obj
14 | /// The objects are records or plain objects and some of their fields differ.
15 | | Record of fields: IReadOnlyList
16 | /// The objects are F# unions with different cases.
17 | | UnionCase of caseName1: string * caseName2: string
18 | /// The objects are F# unions with the same case but some of their fields differ.
19 | | UnionField of case: string * fields: IReadOnlyList
20 | /// The objects are collections and their lengths and/or some of their items differ.
21 | | Collection of count1: int * count2: int * items: IReadOnlyList
22 | /// The objects are dictionaries and some items are only present in one of them and/or some of their items differ.
23 | | Dictionary of keysInX1: IReadOnlyList * keysInX2: IReadOnlyList * common: IReadOnlyList
24 | /// The objects are considered different by a custom differ.
25 | | Custom of ICustomDiff
26 |
27 | static member MakeCustom(printer: System.Func<_, _, _, _, _, _>) =
28 | Custom { new ICustomDiff with member _.WriteTo(w, p, i, pa, r) = printer.Invoke(w, p, i, pa, r) }
29 |
30 | /// A computed diff between two values of a field.
31 | and [] FieldDiff =
32 | {
33 | /// The name of the field.
34 | Name: string
35 | /// The diff between the values.
36 | Diff: Diff
37 | }
38 |
39 | /// Parameterize the display of a diff.
40 | and PrintParams =
41 | {
42 | /// The string used to indent items. Default: " "
43 | indent: string
44 | /// The name given to the first object. Default: "Expect"
45 | x1Name: string
46 | /// The name given to the second object. Default: "Actual"
47 | x2Name: string
48 | /// The common name given to both objects. Default: "Value"
49 | neutralName: string
50 | /// Ensure that Expect and Actual remain aligned even if there is text before the first line
51 | /// by prepending a newline if the diff is a single Value.
52 | /// Default: true for Assert(), false for Write() and ToString().
53 | ensureFirstLineIsAligned: bool
54 | }
55 |
56 | /// A custom computed diff that.
57 | and ICustomDiff =
58 | /// Print this diff.
59 | /// The writer to write the diff to.
60 | /// The printing parameters.
61 | /// The current indentation level. Should always be a replication of param.indent.
62 | /// The drilled-down path to access the currently diffed values from the root objects.
63 | /// The function to call to recursively print an inner diff.
64 | /// Takes indent, path and the inner diff to print as arguments.
65 | abstract WriteTo : writer: TextWriter * param: PrintParams * indent: string * path: string * recur: (string -> string -> Diff -> unit) -> unit
66 |
67 | /// A differ for a specific type.
68 | type IDiffer<'T> =
69 | /// Diff two values.
70 | abstract Diff : x1: 'T * x2: 'T -> Diff option
71 |
72 | /// Generates a differ for any given type.
73 | type IDifferFactory =
74 | /// Get the differ for a given type.
75 | abstract GetDiffer<'T> : unit -> IDiffer<'T>
76 |
77 | /// Generates a differ for a specific type or set of types.
78 | type ICustomDiffer =
79 | /// Get the differ for this type.
80 | /// The factory to use to get differs for nested values.
81 | /// The TypeShape for the current type.
82 | /// A differ for this type, or None if this custom differ doesn't handle this type.
83 | abstract GetCustomDiffer<'T> : differFactory: IDifferFactory * shape: TypeShape<'T> -> IDiffer<'T> option
84 |
85 | type NoCustomDiffer() =
86 | interface ICustomDiffer with
87 | member _.GetCustomDiffer(_, _) = None
88 |
89 | type CombinedCustomDiffer(customDiffers: seq) =
90 | interface ICustomDiffer with
91 | member _.GetCustomDiffer(differ, shape) =
92 | customDiffers |> Seq.tryPick (fun customDiffer -> customDiffer.GetCustomDiffer(differ, shape))
93 |
94 | /// Thrown when the differ found differences between two objects.
95 | type AssertionFailedException(diff: string) =
96 | inherit System.Exception(diff)
97 |
98 | /// Thrown when the differ factory couldn't build a differ for a type.
99 | type DifferConstructionFailedException(message: string, [] innerException: exn) =
100 | inherit System.Exception(message, innerException)
101 |
--------------------------------------------------------------------------------
/src/Diffract/paket.references:
--------------------------------------------------------------------------------
1 | FSharp.Core
2 | TypeShape
--------------------------------------------------------------------------------
/src/Diffract/paket.template:
--------------------------------------------------------------------------------
1 | type project
2 | name DEdge.Diffract
3 | licenseExpression MIT
4 | licenseUrl https://licenses.nuget.org/MIT
5 | authors Loïc Denuzière
6 | copyright Copyright 2021 D-EDGE
7 | description
8 | Display a readable diff between two objects
9 | projectUrl https://github.com/d-edge/Diffract
10 | repositoryUrl https://github.com/d-edge/Diffract
11 | repositoryType git
12 | requireLicenseAcceptance true
13 | tags DEdge;Diffract;Diff;Equals;Test;Comparison
14 | iconUrl https://raw.githubusercontent.com/d-edge/Diffract/main/diffract-64x64.png
15 | readme README.md
16 | files
17 | README.md ==> .
18 |
--------------------------------------------------------------------------------
/tests/Diffract.CSharp.Tests/CustomDiffers.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.FSharp.Core;
2 | using TypeShape.Core;
3 | using Xunit;
4 |
5 | namespace DEdge.Diffract.CSharp.Tests
6 | {
7 | public class CustomDiffers
8 | {
9 | [Fact]
10 | public void NoCustomDiffer()
11 | {
12 | var expectedDiff = @"DDDDD.XXX Expect = ""a""
13 | Actual = ""b""
14 | ";
15 | var expected = new Container(new CustomDiffable("a"));
16 | var actual = new Container(new CustomDiffable("b"));
17 | var actualDiff = Differ.ToString(expected, actual);
18 | AssertStr(expectedDiff, actualDiff);
19 | }
20 |
21 | [Fact]
22 | public void CustomDiffer()
23 | {
24 | var expectedDiff = @"DDDDD Expect = ""a""
25 | Actual = ""b""
26 | ";
27 | var expected = new Container(new CustomDiffable("a"));
28 | var actual = new Container(new CustomDiffable("b"));
29 | var actualDiff = MyDiffer.Get().ToString(expected, actual);
30 | AssertStr(expectedDiff, actualDiff);
31 | }
32 |
33 | [Fact]
34 | public void CustomDifferWithCombinators()
35 | {
36 | var expectedDiff = @"DDDDD Expect = ""a""
37 | Actual = ""b""
38 | ";
39 | var expected = new Container(new CustomDiffable("a"));
40 | var actual = new Container(new CustomDiffable("b"));
41 | var actualDiff = MyDifferWithCombinators.Get().ToString(expected, actual);
42 | AssertStr(expectedDiff, actualDiff);
43 | }
44 |
45 | [Fact]
46 | public void CustomDifferWithMap()
47 | {
48 | var expectedDiff = @"DDDDD Expect = ""a""
49 | Actual = ""b""
50 | ";
51 | var expected = new Container(new CustomDiffable("a"));
52 | var actual = new Container(new CustomDiffable("b"));
53 | var actualDiff = MyDifferWithMap.Get().ToString(expected, actual);
54 | AssertStr(expectedDiff, actualDiff);
55 | }
56 |
57 | [Fact]
58 | public void CustomDifferWithMapToAnonymousObject()
59 | {
60 | var expectedDiff = @"DDDDD.v Expect = ""a""
61 | Actual = ""b""
62 | ";
63 | var expected = new Container(new CustomDiffable("a"));
64 | var actual = new Container(new CustomDiffable("b"));
65 | var actualDiff = MyDifferWithMapToAnonymousObject.Get().ToString(expected, actual);
66 | AssertStr(expectedDiff, actualDiff);
67 | }
68 |
69 | public record CustomDiffable(string XXX);
70 |
71 | public record Container(CustomDiffable DDDDD);
72 |
73 | public class MyDiffer : IDiffer
74 | {
75 | private readonly IDiffer _stringDiffer;
76 |
77 | public MyDiffer(IDifferFactory differFactory)
78 | {
79 | _stringDiffer = differFactory.GetDiffer();
80 | }
81 |
82 | public FSharpOption Diff(CustomDiffable x1, CustomDiffable x2) =>
83 | _stringDiffer.Diff(x1.XXX, x2.XXX);
84 |
85 | public static IDiffer Get() => Singleton.Instance;
86 |
87 | private static class Singleton
88 | {
89 | public static readonly IDiffer Instance = new CustomDiffer().GetDiffer();
90 | }
91 |
92 | private class CustomDiffer : ICustomDiffer
93 | {
94 | public FSharpOption> GetCustomDiffer(IDifferFactory differFactory, Core.TypeShape shape) =>
95 | shape.Type == typeof(CustomDiffable)
96 | ? new MyDiffer(differFactory).Unwrap()
97 | : null;
98 | }
99 | }
100 |
101 | public static class MyDifferWithCombinators
102 | {
103 | public static IDiffer Get() => Singleton.Instance;
104 |
105 | private static class Singleton
106 | {
107 | public static readonly IDiffer Instance = CustomDiffer.GetDiffer();
108 | }
109 |
110 | private static readonly ICustomDiffer CustomDiffer =
111 | CustomDiffer.Build(factory =>
112 | {
113 | var stringDiffer = factory.GetDiffer();
114 | return (x1, x2) => stringDiffer.Diff(x1.XXX, x2.XXX);
115 | });
116 | }
117 |
118 | public static class MyDifferWithMap
119 | {
120 | public static IDiffer Get() => Singleton.Instance;
121 |
122 | private static class Singleton
123 | {
124 | public static readonly IDiffer Instance = CustomDiffer.GetDiffer();
125 | }
126 |
127 | private static readonly ICustomDiffer CustomDiffer =
128 | CustomDiffer.Map(x => x.XXX);
129 | }
130 |
131 | public static class MyDifferWithMapToAnonymousObject
132 | {
133 | public static IDiffer Get() => Singleton.Instance;
134 |
135 | private static class Singleton
136 | {
137 | public static readonly IDiffer Instance = CustomDiffer.GetDiffer();
138 | }
139 |
140 | private static readonly ICustomDiffer CustomDiffer =
141 | CustomDiffer.Map(x => new { v = x.XXX });
142 | }
143 |
144 | private void AssertStr(string expected, string actual)
145 | {
146 | Assert.Equal(expected.Replace("\r\n", "\n"), actual);
147 | }
148 | }
149 | }
--------------------------------------------------------------------------------
/tests/Diffract.CSharp.Tests/Diffract.CSharp.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net8.0
4 | false
5 | DEdge.Diffract.CSharp.Tests
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/tests/Diffract.CSharp.Tests/Tests.cs:
--------------------------------------------------------------------------------
1 | using Xunit;
2 |
3 | namespace DEdge.Diffract.CSharp.Tests
4 | {
5 | public class Tests
6 | {
7 | [Fact]
8 | public void Poco()
9 | {
10 | var expected = new MyPoco { Item = new MyInnerPoco(1, "a", 1) };
11 | var actual = new MyPoco { Item = new MyInnerPoco(2, "a", 2) };
12 | Assert.Equal("Item.X Expect = 1\n Actual = 2\n",
13 | Differ.ToString(expected, actual));
14 | }
15 |
16 | [Fact]
17 | public void Record()
18 | {
19 | var expected = new MyRecord(1, "a");
20 | var actual = new MyRecord(2, "a");
21 | Assert.Equal("X Expect = 1\n Actual = 2\n",
22 | Differ.ToString(expected, actual));
23 | }
24 |
25 | public class MyInnerPoco
26 | {
27 | public int X { get; }
28 | public string Y { get; }
29 | private int Z { get; }
30 |
31 | public MyInnerPoco(int x, string y, int z)
32 | {
33 | X = x;
34 | Y = y;
35 | Z = z;
36 | }
37 | }
38 |
39 | public class MyPoco
40 | {
41 | public MyInnerPoco Item { get; init; }
42 | }
43 |
44 | public record MyRecord(int X, string Y);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/tests/Diffract.CSharp.Tests/paket.references:
--------------------------------------------------------------------------------
1 | group test
2 | Microsoft.NET.Test.Sdk
3 | xunit
4 | xunit.runner.visualstudio
5 | FsCheck
6 | FsCheck.Xunit
--------------------------------------------------------------------------------
/tests/Diffract.Tests/Diffract.Tests.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | false
6 | false
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/tests/Diffract.Tests/Program.fs:
--------------------------------------------------------------------------------
1 | module Program = let [] main _ = 0
2 |
--------------------------------------------------------------------------------
/tests/Diffract.Tests/Tests.fs:
--------------------------------------------------------------------------------
1 | module Tests
2 |
3 | #nowarn "40"
4 |
5 | open Xunit
6 | open FsCheck
7 | open FsCheck.Xunit
8 | open DEdge.Diffract
9 |
10 | type Foo = { xxxxx: int; yyyyy: bool }
11 | type U =
12 | | U1 of int
13 | | U2 of x: int * y: int
14 | type Bar = { aaa: Foo; bbb: U }
15 |
16 | type Baz = { xx: int; yy: string }
17 |
18 | []
19 | type Class(x: int) =
20 | member _.X = x
21 |
22 | let assertStr (expected: string, actual: string) =
23 | Assert.Equal(expected.Replace("\r\n", "\n"), actual)
24 |
25 | []
26 | let ``No exception for equal values`` (x: Bar) =
27 | Differ.Assert(x, x)
28 |
29 | []
30 | let ``Exception for non-equal values`` (x: Bar) (y: Bar) =
31 | x <> y ==> lazy
32 | Assert.Throws(fun () ->
33 | Differ.Assert(x, y))
34 | |> ignore
35 |
36 | []
37 | let ``Both have null leaf`` () =
38 | let actual = Differ.Diff({ xx = 1; yy = null }, { xx = 1; yy = null })
39 | Assert.Equal(None, actual)
40 |
41 | []
42 | let ``Expected has null leaf`` () =
43 | let actual = Differ.Diff({ xx = 1; yy = null }, { xx = 1; yy = "a" })
44 | Assert.Equal(Some(Diff.Record [ { Name = "yy"; Diff = Diff.Value(null, "a") } ]), actual)
45 |
46 | let actual = Differ.ToString({ xx = 1; yy = null }, { xx = 1; yy = "a" })
47 | assertStr("\
48 | yy Expect is null
49 | Actual = \"a\"
50 | ", actual)
51 |
52 | []
53 | let ``Actual has null leaf`` () =
54 | let actual = Differ.Diff({ xx = 1; yy = "a" }, { xx = 1; yy = null })
55 | Assert.Equal(Some(Diff.Record [ { Name = "yy"; Diff = Diff.Value("a", null) } ]), actual)
56 |
57 | let actual = Differ.ToString({ xx = 1; yy = "a" }, { xx = 1; yy = null })
58 | assertStr("\
59 | yy Expect = \"a\"
60 | Actual is null
61 | ", actual)
62 |
63 | []
64 | let ``Both are null`` () =
65 | let actual = Differ.Diff((null: Class), null)
66 | Assert.Equal(None, actual)
67 |
68 | []
69 | let ``Expected is null`` () =
70 | let actualValue = Class(3)
71 | let actual = Differ.Diff(null, actualValue)
72 | Assert.Equal(Some(Diff.Nullness(null, actualValue)), actual)
73 |
74 | let actual = Differ.ToString(null, actualValue)
75 | assertStr("\
76 | Expect is null
77 | Actual is not null
78 | ", actual)
79 |
80 | []
81 | let ``Actual is null`` () =
82 | let actualValue = Class(3)
83 | let actual = Differ.Diff(actualValue, null)
84 | Assert.Equal(Some(Diff.Nullness(actualValue, null)), actual)
85 |
86 | let actual = Differ.ToString(actualValue, null)
87 | assertStr("\
88 | Expect is not null
89 | Actual is null
90 | ", actual)
91 |
92 | []
93 | let ``List diff`` (l1: int list) (l2: int list) =
94 | let d = Differ.Diff(l1, l2)
95 | if l1 = l2 then
96 | d = None
97 | else
98 | let expectedDiffs =
99 | (l1, l2)
100 | ||> Seq.mapi2 (fun i x1 x2 -> Differ.Diff(x1, x2) |> Option.map (fun d -> { Name = string i; Diff = d }))
101 | |> Seq.choose id
102 | |> List.ofSeq
103 | d = Some (Diff.Collection (l1.Length, l2.Length, expectedDiffs))
104 |
105 | []
106 | let ``Example output`` () =
107 | assertStr("\
108 | Value differs by 2 fields:
109 | aaa.yyyyy Expect = true
110 | Actual = false
111 | bbb differs by union case:
112 | Expect is U1
113 | Actual is U2
114 | ",
115 | Differ.ToString(
116 | { aaa = { xxxxx = 1; yyyyy = true }
117 | bbb = U1 1 },
118 | { aaa = { xxxxx = 1; yyyyy = false }
119 | bbb = U2 (1, 2) }))
120 |
121 | []
122 | let ``Example error message`` () =
123 | let ex = Assert.Throws(fun () ->
124 | Differ.Assert(
125 | { aaa = { xxxxx = 1; yyyyy = true }
126 | bbb = U1 1 },
127 | { aaa = { xxxxx = 1; yyyyy = false }
128 | bbb = U2 (1, 2) }))
129 | assertStr("\
130 | Value differs by 2 fields:
131 | aaa.yyyyy Expect = true
132 | Actual = false
133 | bbb differs by union case:
134 | Expect is U1
135 | Actual is U2
136 | ",
137 | ex.Message)
138 |
139 | []
140 | let ``Ensure first line is aligned`` () =
141 | let ex = Assert.Throws(fun () -> Differ.Assert(12, 13))
142 | assertStr("
143 | Expect = 12
144 | Actual = 13
145 | ", ex.Message)
146 | assertStr("\
147 | Expect = 12
148 | Actual = 13
149 | ", Differ.ToString(12, 13))
150 |
151 | []
152 | let ``Example collection`` () =
153 | assertStr("\
154 | xxx collection differs:
155 | xxx.Count Expect = 2
156 | Actual = 3
157 | xxx[1] Expect = 3
158 | Actual = 2
159 | ",
160 | Differ.ToString(
161 | {| xxx = [1; 3] |},
162 | {| xxx = [1; 2; 3] |}))
163 |
164 | type CustomDiffable = { xxx: string }
165 |
166 | module MyDiffModule =
167 |
168 | type CustomDiffer() =
169 | interface ICustomDiffer with
170 | member this.GetCustomDiffer<'T>(differFactory, shape) =
171 | if shape.Type = typeof then
172 | let differ = differFactory.GetDiffer()
173 | { new IDiffer with
174 | member _.Diff(x1, x2) = differ.Diff(x1.xxx, x2.xxx) }
175 | |> unbox>
176 | |> Some
177 | else
178 | None
179 |
180 | let differ<'T> = CustomDiffer().GetDiffer<'T>()
181 |
182 | module MyDiffWithCombinators =
183 |
184 | let customDiffer = CustomDiffer.Build(fun factory ->
185 | let stringDiffer = factory.GetDiffer()
186 | fun x1 x2 -> stringDiffer.Diff(x1.xxx, x2.xxx))
187 |
188 | let differ<'T> = customDiffer.GetDiffer<'T>()
189 |
190 | type MyDiffer(differFactory: IDifferFactory) =
191 | let stringDiffer = differFactory.GetDiffer()
192 |
193 | interface IDiffer with
194 | member _.Diff(x1, x2) = stringDiffer.Diff(x1.xxx, x2.xxx)
195 |
196 | type MyCustomDiffer() =
197 | interface ICustomDiffer with
198 | member this.GetCustomDiffer<'T>(differFactory, shape) =
199 | if shape.Type = typeof then
200 | MyDiffer(differFactory).Unwrap()
201 | else
202 | None
203 |
204 | type MyDiffType<'T>() =
205 | static member val Differ = MyCustomDiffer().GetDiffer<'T>()
206 |
207 | type MyDiffTypeWithCombinators<'T>() =
208 | static let customDiffer = CustomDiffer.Build(fun factory ->
209 | let stringDiffer = factory.GetDiffer()
210 | fun x1 x2 -> stringDiffer.Diff(x1.xxx, x2.xxx))
211 |
212 | static member val Differ = customDiffer.GetDiffer<'T>()
213 |
214 | []
215 | let ``Custom differ`` () =
216 | assertStr("\
217 | xxx Expect = \"a\"
218 | Actual = \"b\"
219 | ",
220 | Differ.ToString({ xxx = "a" }, { xxx = "b" }))
221 | assertStr("\
222 | Expect = \"a\"
223 | Actual = \"b\"
224 | ",
225 | Differ.ToString({ xxx = "a" }, { xxx = "b" }, MyDiffModule.differ))
226 | assertStr("\
227 | Expect = \"a\"
228 | Actual = \"b\"
229 | ",
230 | Differ.ToString({ xxx = "a" }, { xxx = "b" }, MyDiffWithCombinators.differ))
231 | assertStr("\
232 | Expect = \"a\"
233 | Actual = \"b\"
234 | ",
235 | Differ.ToString({ xxx = "a" }, { xxx = "b" }, MyDiffType.Differ))
236 | assertStr("\
237 | Expect = \"a\"
238 | Actual = \"b\"
239 | ",
240 | Differ.ToString({ xxx = "a" }, { xxx = "b" }, MyDiffTypeWithCombinators.Differ))
241 |
242 | module ``Custom differ with custom diff output`` =
243 |
244 | let myCustomDiffer = CustomDiffer.Build(fun x1 x2 ->
245 | if x1.xxx = x2.xxx then
246 | None
247 | else
248 | Diff.MakeCustom(fun writer param indent path recur ->
249 | if param.ensureFirstLineIsAligned then writer.WriteLine()
250 | let indentLike str = String.replicate (String.length str) " "
251 | let dpath = if path = "" then "" else path + " "
252 | writer.WriteLine($"{indent}{dpath}{param.x1Name} __is__ {x1.xxx}")
253 | writer.WriteLine($"{indent}{indentLike dpath}{param.x2Name} __is__ {x2.xxx}"))
254 | |> Some)
255 |
256 | let differ<'T> = myCustomDiffer.GetDiffer<'T>()
257 |
258 | []
259 | let ``Assert with immediate value adds newline`` () =
260 | let ex = Assert.Throws(fun () ->
261 | Differ.Assert({ xxx = "a" }, { xxx = "b" }, differ))
262 | assertStr("
263 | Expect __is__ a
264 | Actual __is__ b
265 | ", ex.Message)
266 |
267 | []
268 | let ``Assert with nested value doesn't add newline`` () =
269 | let ex = Assert.Throws(fun () ->
270 | Differ.Assert({| iiiii = { xxx = "a" } |}, {| iiiii = { xxx = "b" } |}, differ))
271 | assertStr("\
272 | iiiii Expect __is__ a
273 | Actual __is__ b
274 | ", ex.Message)
275 |
276 | []
277 | let ``ToString with immediate value doesn't add newline`` () =
278 | let diff = Differ.ToString({ xxx = "a" }, { xxx = "b" }, differ)
279 | assertStr("\
280 | Expect __is__ a
281 | Actual __is__ b
282 | ", diff)
283 |
284 | []
285 | let ``ToString with nested value doesn't add newline`` () =
286 | let diff = Differ.ToString({| iiiii = { xxx = "a" } |}, {| iiiii = { xxx = "b" } |}, differ)
287 | assertStr("\
288 | iiiii Expect __is__ a
289 | Actual __is__ b
290 | ", diff)
291 |
292 | type Rec = { xRec: Rec option }
293 |
294 | []
295 | let ``Recursive type`` () =
296 | let x1 = { xRec = Some { xRec = None } }
297 | let x2 = { xRec = Some { xRec = Some { xRec = None } } }
298 | assertStr("\
299 | xRec.Value.xRec differs by union case:
300 | Expect is None
301 | Actual is Some
302 | ",
303 | Differ.ToString(x1, x2))
304 |
305 | []
306 | let ``Anonymous record`` () =
307 | Assert.Null(Differ.Diff({| xxx = 1; yyy = "2" |}, {| xxx = 1; yyy = "2" |}))
308 | assertStr("\
309 | xxx Expect = 1
310 | Actual = 2
311 | ",
312 | Differ.ToString({| xxx = 1; yyy = "2" |}, {| xxx = 2; yyy = "2" |}))
313 |
--------------------------------------------------------------------------------
/tests/Diffract.Tests/paket.references:
--------------------------------------------------------------------------------
1 | group test
2 | Microsoft.NET.Test.Sdk
3 | xunit
4 | xunit.runner.visualstudio
5 | FsCheck
6 | FsCheck.Xunit
--------------------------------------------------------------------------------
/tests/Test.fsx:
--------------------------------------------------------------------------------
1 | #r "nuget: TypeShape"
2 | #load "../src/Diffract/Types.fs"
3 | #load "../src/Diffract/ReadOnlyDictionaryShape.fs"
4 | #load "../src/Diffract/Differ.fs"
5 | #load "../src/Diffract/DiffPrinter.fs"
6 | #load "../src/Diffract/Diffract.fs"
7 | #load "../src/Diffract/Extensions.fs"
8 |
9 | open Diffract
10 |
11 | type Foo = { x: int; y: float }
12 | type U =
13 | | U1 of int
14 | | U2 of x: int * y: int
15 | type Bar = { a: Foo; b: U }
16 |
17 | Differ.simple.Print(
18 | [ { a = { x = 2; y = 1. }
19 | b = U2 (2, 1) }
20 | { a = { x = 2; y = 1. }
21 | b = U2 (2, 1) } ],
22 | [ { a = { x = 2; y = 1. }
23 | b = U2 (2, 1) }
24 | { a = { x = 1; y = 1. }
25 | b = U2 (2, 3) } ])
26 |
27 | Differ.simple.Print(
28 | Map [(1, 3); (2, 2); (3, 1)],
29 | Map [(2, 2); (3, 2); (4, 1)])
30 |
--------------------------------------------------------------------------------