From e395aa9283147378a9c786b0f31003499987c276 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Thu, 19 Mar 2026 17:58:52 +0100 Subject: [PATCH 01/43] Reroute the docs link to Xceed (#3183) Co-authored-by: Dennis Doomen --- docs/_data/navigation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 3b9abbe76..2b1a285cb 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -3,7 +3,7 @@ main: - title: "About" url: /about/ - title: "Documentation" - url: https://doc.xceed.com + url: https://xceed.com/documentation/xceed-fluent-assertions-for-net/ - title: "Releases" url: /releases/ - title: "GitHub" From e179bb0aa07a35c1d629c55087978029e5b5285b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:27:43 -0700 Subject: [PATCH 02/43] Bump flatted in the npm_and_yarn group across 1 directory (#3184) Bumps the npm_and_yarn group with 1 update in the / directory: [flatted](https://github.com/WebReflection/flatted). Updates `flatted` from 3.4.1 to 3.4.2 - [Commits](https://github.com/WebReflection/flatted/compare/v3.4.1...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddc569d71..927456df1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -903,9 +903,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "license": "ISC" }, "node_modules/gensequence": { From 2a6f493bebb5bde6cc709cae87038bae5fd48971 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:23:07 -0700 Subject: [PATCH 03/43] Add AI assistant instruction file (agents.md) for Copilot, Claude, and JetBrains Junie (#3176) --- .github/copilot-instructions.md | 1 + .junie/guidelines.md | 1 + CLAUDE.md | 1 + agents.md | 196 ++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 .junie/guidelines.md create mode 100644 CLAUDE.md create mode 100644 agents.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..c1eb48e7e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +The guidelines for this repository are maintained in [agents.md](../agents.md) at the root of the repository. diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 000000000..c1eb48e7e --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1 @@ +The guidelines for this repository are maintained in [agents.md](../agents.md) at the root of the repository. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..79709e3ef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@agents.md diff --git a/agents.md b/agents.md new file mode 100644 index 000000000..913b61009 --- /dev/null +++ b/agents.md @@ -0,0 +1,196 @@ +# Fluent Assertions – AI Agent Guidelines + +## Project Overview + +Fluent Assertions is a .NET library providing a rich set of fluent extension methods that allow developers to more naturally specify the expected outcome of test assertions. It supports multiple .NET test frameworks (xUnit, NUnit, MSTest, MSpec, TUnit, etc.) and targets `net47`, `net6.0`, and `net8.0`. + +The source lives in `Src/FluentAssertions/` and the tests in `Tests/FluentAssertions.Specs/` and `Tests/FluentAssertions.Equivalency.Specs/`. + +## Build & Test Commands + +```bash +# Build +dotnet build + +# Run all tests +dotnet test + +# Run tests for a specific project +dotnet test Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj + +# Spell check documentation (run before pushing) +./build.sh --target spellcheck # Linux/macOS +.\build.ps1 --target spellcheck # Windows + +# Accept intentional public API changes (after running approval tests) +./AcceptApiChanges.sh # Linux/macOS +.\AcceptApiChanges.ps1 # Windows +``` + +The project uses a [Nuke](https://nuke.build/)-based build system. The build scripts `build.sh` / `build.ps1` / `build.cmd` are thin wrappers around Nuke and support all standard Nuke targets. + +## Contributing Workflow + +- Always target the `main` branch for pull requests +- Prefer rebase over merge when updating a local branch +- **Any change to the public API requires prior approval**: open a GitHub issue, get it labeled `api-approved`, then open the PR +- After intentional public API changes, run `AcceptApiChanges.sh` / `AcceptApiChanges.ps1` to update the API approval baselines in `Tests/Approval.Tests/` +- Update `docs/_pages/releases.md` when adding features or fixing bugs +- Update `docs/_pages/` documentation when assertions are added or changed +- Run the spell checker before pushing: `./build.sh --target spellcheck` + +## Code Style + +Follow the [C# Coding Guidelines](https://csharpcodingguidelines.com/). Key rules enforced by `.editorconfig`: + +- 4 spaces indentation; max line length of 130 characters +- CRLF line endings +- Opening braces on their own line (`csharp_new_line_before_open_brace = all`) +- Use language keywords instead of BCL types (`int` not `Int32`, `string` not `String`) +- Prefer pattern matching over casts (`is` patterns, not `as` + null check) +- Avoid `this.` qualifier unless required for disambiguation +- Use `var` only when the type is immediately apparent from the right-hand side +- Access modifiers are required on all non-interface members +- `readonly` is required on fields that are never reassigned +- Constant fields use PascalCase + +## Assertion Classes + +All assertion classes follow this dual-class pattern (concrete + generic base) to support both simple use and extensibility via inheritance: + +```csharp +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using FluentAssertions.Execution; + +namespace FluentAssertions.Primitives; + +/// +/// Contains a number of methods to assert that a is in the expected state. +/// +[DebuggerNonUserCode] +public class FooAssertions : FooAssertions +{ + public FooAssertions(Foo value, AssertionChain assertionChain) + : base(value, assertionChain) + { + } +} + +#pragma warning disable CS0659, S1206 // Ignore not overriding Object.GetHashCode() +#pragma warning disable CA1065 // Ignore throwing NotSupportedException from Equals +/// +/// Contains a number of methods to assert that a is in the expected state. +/// +[DebuggerNonUserCode] +public class FooAssertions + where TAssertions : FooAssertions +{ + private readonly AssertionChain assertionChain; + + public FooAssertions(Foo value, AssertionChain assertionChain) + { + this.assertionChain = assertionChain; + Subject = value; + } + + /// + /// Gets the object whose value is being asserted. + /// + public Foo Subject { get; } + + /// + /// Asserts that the foo satisfies some condition. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public AndConstraint BeSomething( + [StringSyntax("CompositeFormat")] string because = "", + params object[] becauseArgs) + { + assertionChain + .ForCondition(/* condition */) + .BecauseOf(because, becauseArgs) + .FailWith("Expected {context:foo} to be something{reason}."); + + return new AndConstraint((TAssertions)this); + } +} +``` + +Key points: +- All assertion classes must have `[DebuggerNonUserCode]` +- Generic parameter is `TAssertions` constrained to the class itself +- `Subject` property exposes the value under test +- Assertion methods return `AndConstraint` or `AndWhichConstraint` to support chaining (`.And`) +- `because` / `becauseArgs` parameters are required on every assertion method +- `because` is decorated with `[StringSyntax("CompositeFormat")]` +- Failure messages use `{context:typename}` for the subject reference and `{reason}` for the `because` clause +- The `Should()` extension method for new types is added to `Src/FluentAssertions/AssertionExtensions.cs` +- XML doc comments on `Should()` extension methods follow the pattern: `Returns an object that can be used to assert the current .` + +## Test Conventions + +Tests use xUnit and the Arrange-Act-Assert (AAA) pattern. Each spec class is a partial class split into one file per assertion method. + +**File naming**: `FooAssertionSpecs.BeSomething.cs` (partial class file per assertion method) + +```csharp +using System; +using Xunit; +using Xunit.Sdk; + +namespace FluentAssertions.Specs.Primitives; + +public partial class FooAssertionSpecs +{ + public class BeSomething // One nested class per assertion method + { + [Fact] + public void Succeeds_for_foo_with_the_expected_value() + { + // Arrange + var subject = new Foo(expectedValue); + + // Act / Assert + subject.Should().BeSomething(); + } + + [Fact] + public void The_foo_must_satisfy_some_condition() + { + // Arrange + var subject = new Foo(unexpectedValue); + + // Act + Action act = () => subject.Should().BeSomething(); + + // Assert + act.Should().Throw() + .WithMessage("Expected foo to be something*"); + } + + [Fact] + public void Fails_for_null_foo() + { + // Arrange + Foo subject = null; + + // Act + Action act = () => subject.Should().BeSomething(); + + // Assert + act.Should().Throw(); + } + } +} +``` + +Naming rules: +- Use fact-based test method names (e.g. `Succeeds_for_*`, `The_X_must_be_Y`, `An_X_is_required`) – avoid "Should", "When", and "Asserting" +- Separate the "Act" and "Assert" steps only when testing failure paths; success-path tests can combine them as `// Act / Assert` From fdfe953df465a2270051f1cd3abf1a5e524ff330 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:19:17 -0700 Subject: [PATCH 04/43] Bump smol-toml in the npm_and_yarn group across 1 directory (#3185) Bumps the npm_and_yarn group with 1 update in the / directory: [smol-toml](https://github.com/squirrelchat/smol-toml). Updates `smol-toml` from 1.6.0 to 1.6.1 - [Release notes](https://github.com/squirrelchat/smol-toml/releases) - [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.6.0...v1.6.1) --- updated-dependencies: - dependency-name: smol-toml dependency-version: 1.6.1 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 927456df1..8aae6678e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1046,9 +1046,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" From c73b73097ff0e28f87535cfd007eccc06b0612dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:51:24 -0700 Subject: [PATCH 05/43] Bump the npm_and_yarn group across 1 directory with 2 updates (#3186) Bumps the npm_and_yarn group with 2 updates in the / directory: [picomatch](https://github.com/micromatch/picomatch) and [yaml](https://github.com/eemeli/yaml). Updates `picomatch` from 4.0.3 to 4.0.4 - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4) Updates `yaml` from 2.8.2 to 2.8.3 - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.8.2...v2.8.3) --- updated-dependencies: - dependency-name: picomatch dependency-version: 4.0.4 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: yaml dependency-version: 2.8.3 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8aae6678e..66e4aabb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1013,9 +1013,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -1098,9 +1098,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" From efe9635553ce8d1078fcfa47c632cd3bfefcc273 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:28:04 +0200 Subject: [PATCH 06/43] Bump cspell from 9.7.0 to 10.0.0 (#3189) --- NodeVersion | 2 +- package-lock.json | 492 ++++++++++++++++++++-------------------------- package.json | 2 +- 3 files changed, 213 insertions(+), 283 deletions(-) diff --git a/NodeVersion b/NodeVersion index 586e275eb..91d5f6ff8 100644 --- a/NodeVersion +++ b/NodeVersion @@ -1 +1 @@ -20.20.0 +22.18.0 diff --git a/package-lock.json b/package-lock.json index 66e4aabb8..e12e565ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,67 +7,67 @@ "": { "version": "1.0.1", "dependencies": { - "cspell": "^9.7.0" + "cspell": "^10.0.0" } }, "node_modules/@cspell/cspell-bundled-dicts": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-9.7.0.tgz", - "integrity": "sha512-s7h1vo++Q3AsfQa3cs0u/KGwm3SYInuIlC4kjlCBWjQmb4KddiZB5O1u0+3TlA7GycHb5M4CR7MDfHUICgJf+w==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-10.0.0.tgz", + "integrity": "sha512-ci410HEkng2582oOjlRHQtlGXwh+rUC/mVcN9dObLHpKhvPgzn2S6vT56pARstxxZpcCUG/oLhn3dCqdJlVzmA==", "license": "MIT", "dependencies": { "@cspell/dict-ada": "^4.1.1", "@cspell/dict-al": "^1.1.1", "@cspell/dict-aws": "^4.0.17", "@cspell/dict-bash": "^4.2.2", - "@cspell/dict-companies": "^3.2.10", + "@cspell/dict-companies": "^3.2.11", "@cspell/dict-cpp": "^7.0.2", "@cspell/dict-cryptocurrencies": "^5.0.5", "@cspell/dict-csharp": "^4.0.8", - "@cspell/dict-css": "^4.0.19", + "@cspell/dict-css": "^4.1.1", "@cspell/dict-dart": "^2.3.2", "@cspell/dict-data-science": "^2.0.13", "@cspell/dict-django": "^4.1.6", "@cspell/dict-docker": "^1.1.17", - "@cspell/dict-dotnet": "^5.0.12", + "@cspell/dict-dotnet": "^5.0.13", "@cspell/dict-elixir": "^4.0.8", - "@cspell/dict-en_us": "^4.4.29", + "@cspell/dict-en_us": "^4.4.33", "@cspell/dict-en-common-misspellings": "^2.1.12", - "@cspell/dict-en-gb-mit": "^3.1.18", - "@cspell/dict-filetypes": "^3.0.15", + "@cspell/dict-en-gb-mit": "^3.1.22", + "@cspell/dict-filetypes": "^3.0.18", "@cspell/dict-flutter": "^1.1.1", - "@cspell/dict-fonts": "^4.0.5", + "@cspell/dict-fonts": "^4.0.6", "@cspell/dict-fsharp": "^1.1.1", - "@cspell/dict-fullstack": "^3.2.8", + "@cspell/dict-fullstack": "^3.2.9", "@cspell/dict-gaming-terms": "^1.1.2", "@cspell/dict-git": "^3.1.0", "@cspell/dict-golang": "^6.0.26", "@cspell/dict-google": "^1.0.9", "@cspell/dict-haskell": "^4.0.6", - "@cspell/dict-html": "^4.0.14", + "@cspell/dict-html": "^4.0.15", "@cspell/dict-html-symbol-entities": "^4.0.5", "@cspell/dict-java": "^5.0.12", "@cspell/dict-julia": "^1.1.1", "@cspell/dict-k8s": "^1.0.12", "@cspell/dict-kotlin": "^1.1.1", - "@cspell/dict-latex": "^5.0.0", + "@cspell/dict-latex": "^5.1.0", "@cspell/dict-lorem-ipsum": "^4.0.5", "@cspell/dict-lua": "^4.0.8", "@cspell/dict-makefile": "^1.0.5", - "@cspell/dict-markdown": "^2.0.14", + "@cspell/dict-markdown": "^2.0.16", "@cspell/dict-monkeyc": "^1.0.12", "@cspell/dict-node": "^5.0.9", - "@cspell/dict-npm": "^5.2.34", + "@cspell/dict-npm": "^5.2.38", "@cspell/dict-php": "^4.1.1", "@cspell/dict-powershell": "^5.0.15", - "@cspell/dict-public-licenses": "^2.0.15", - "@cspell/dict-python": "^4.2.25", + "@cspell/dict-public-licenses": "^2.0.16", + "@cspell/dict-python": "^4.2.26", "@cspell/dict-r": "^2.1.1", - "@cspell/dict-ruby": "^5.1.0", + "@cspell/dict-ruby": "^5.1.1", "@cspell/dict-rust": "^4.1.2", "@cspell/dict-scala": "^5.0.9", "@cspell/dict-shell": "^1.1.2", - "@cspell/dict-software-terms": "^5.1.21", + "@cspell/dict-software-terms": "^5.2.2", "@cspell/dict-sql": "^2.2.1", "@cspell/dict-svelte": "^1.0.7", "@cspell/dict-swift": "^2.0.6", @@ -77,79 +77,79 @@ "@cspell/dict-zig": "^1.0.0" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/@cspell/cspell-json-reporter": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-9.7.0.tgz", - "integrity": "sha512-6xpGXlMtQA3hV2BCAQcPkpx9eI12I0o01i9eRqSSEDKtxuAnnrejbcCpL+5OboAjTp3/BSeNYSnhuWYLkSITWQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-10.0.0.tgz", + "integrity": "sha512-hq5dui2ngYMZKbBauX7K1tkqlu81sX/uaCO49ZJLPjeZsE1auZLtHehDLfAr/ZXoj/dLYeQMSKiaJyE+qLVPHA==", "license": "MIT", "dependencies": { - "@cspell/cspell-types": "9.7.0" + "@cspell/cspell-types": "10.0.0" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/@cspell/cspell-performance-monitor": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-9.7.0.tgz", - "integrity": "sha512-w1PZIFXuvjnC6mQHyYAFnrsn5MzKnEcEkcK1bj4OG00bAt7WX2VUA/eNNt9c1iHozCQ+FcRYlfbGxuBmNyzSgw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-10.0.0.tgz", + "integrity": "sha512-2vMh2pLt2dg/ArYvWjMP4v9HCm0pRhONsEJyc8oHdZyOYvX7trixX894I0M39+VBf3yWtPCEgYRh1UDXNIZRig==", "license": "MIT", "engines": { - "node": ">=20.18" + "node": ">=22.18.0" } }, "node_modules/@cspell/cspell-pipe": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-9.7.0.tgz", - "integrity": "sha512-iiisyRpJciU9SOHNSi0ZEK0pqbEMFRatI/R4O+trVKb+W44p4MNGClLVRWPGUmsFbZKPJL3jDtz0wPlG0/JCZA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-10.0.0.tgz", + "integrity": "sha512-qcgHhQvtEX8LSwIVsWrdUgiGim52lN3jT+ghlkdp72v+nBcGKsS2frEKTmbGLug+xcqppkzs6Q6VmsFp1MGtfA==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/@cspell/cspell-resolver": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-9.7.0.tgz", - "integrity": "sha512-uiEgS238mdabDnwavo6HXt8K98jlh/jpm7NONroM9NTr9rzck2VZKD2kXEj85wDNMtRsRXNoywTjwQ8WTB6/+w==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-10.0.0.tgz", + "integrity": "sha512-8H+IUDB7SmrpcRugQ5f55qG81ZShk6nQRk+natLz41TEY98D8/LCmjHEkh/vhDPph9pVJmNUp7JcM2E1UHEa2g==", "license": "MIT", "dependencies": { "global-directory": "^5.0.0" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/@cspell/cspell-service-bus": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-9.7.0.tgz", - "integrity": "sha512-fkqtaCkg4jY/FotmzjhIavbXuH0AgUJxZk78Ktf4XlhqOZ4wDeUWrCf220bva4mh3TWiLx/ae9lIlpl59Vx6hA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-10.0.0.tgz", + "integrity": "sha512-V7eigqg/TOoKwNK4Q18wr9KGxA8U5SFcoWVS8RyAxv4mQ+yNKHhvHEbRBifjPbQDer66afOrclb2UbqkIy2SOw==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/@cspell/cspell-types": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-9.7.0.tgz", - "integrity": "sha512-Tdfx4eH2uS+gv9V9NCr3Rz+c7RSS6ntXp3Blliud18ibRUlRxO9dTaOjG4iv4x0nAmMeedP1ORkEpeXSkh2QiQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-10.0.0.tgz", + "integrity": "sha512-IQA++Idqb8fZzkCbHq3+T+9yG9WpeaBxomOrG2KcR/Pj0CgnovzuApYKL2cc35UWLePboKinMeqEPiweFpHVug==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/@cspell/cspell-worker": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-worker/-/cspell-worker-9.7.0.tgz", - "integrity": "sha512-cjEApFF0aOAa1vTUk+e7xP8ofK7iC7hsRzj1FmvvVQz8PoLWPRaq+1bT89ypPsZQvavqm5sIgb97S60/aW4TVg==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-worker/-/cspell-worker-10.0.0.tgz", + "integrity": "sha512-V5bjMldNksilnja3fu8muQmkW5/guyua1yNVOhoE2r7othSvjuDlGMl8g2bQSrWjp+UXu0dP/BEZ6JC/IfNwTA==", "license": "MIT", "dependencies": { - "cspell-lib": "9.7.0" + "cspell-lib": "10.0.0" }, "engines": { - "node": ">=20.18" + "node": ">=22.18.0" } }, "node_modules/@cspell/dict-ada": { @@ -180,9 +180,9 @@ } }, "node_modules/@cspell/dict-companies": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.10.tgz", - "integrity": "sha512-bJ1qnO1DkTn7JYGXvxp8FRQc4yq6tRXnrII+jbP8hHmq5TX5o1Wu+rdfpoUQaMWTl6balRvcMYiINDesnpR9Bw==", + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.11.tgz", + "integrity": "sha512-0cmafbcz2pTHXLd59eLR1gvDvN6aWAOM0+cIL4LLF9GX9yB2iKDNrKsvs4tJRqutoaTdwNFBbV0FYv+6iCtebQ==", "license": "MIT" }, "node_modules/@cspell/dict-cpp": { @@ -204,9 +204,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-css": { - "version": "4.0.19", - "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.19.tgz", - "integrity": "sha512-VYHtPnZt/Zd/ATbW3rtexWpBnHUohUrQOHff/2JBhsVgxOrksAxJnLAO43Q1ayLJBJUUwNVo+RU0sx0aaysZfg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.1.tgz", + "integrity": "sha512-y/Vgo6qY08e1t9OqR56qjoFLBCpi4QfWMf2qzD1l9omRZwvSMQGRPz4x0bxkkkU4oocMAeztjzCsmLew//c/8w==", "license": "MIT" }, "node_modules/@cspell/dict-dart": { @@ -234,9 +234,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-dotnet": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.12.tgz", - "integrity": "sha512-FiV934kNieIjGTkiApu/WKvLYi/KBpvfWB2TSqpDQtmXZlt3uSa5blwblO1ZC8OvjH8RCq/31H5IdEYmTaZS7A==", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.13.tgz", + "integrity": "sha512-xPp7jMnFpOri7tzmqmm/dXMolXz1t2bhNqxYkOyMqXhvs08oc7BFs+EsbDY0X7hqiISgeFZGNqn0dOCr+ncPYw==", "license": "MIT" }, "node_modules/@cspell/dict-elixir": { @@ -246,9 +246,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-en_us": { - "version": "4.4.29", - "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.29.tgz", - "integrity": "sha512-G3B27++9ziRdgbrY/G/QZdFAnMzzx17u8nCb2Xyd4q6luLpzViRM/CW3jA+Mb/cGT5zR/9N+Yz9SrGu1s0bq7g==", + "version": "4.4.33", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.33.tgz", + "integrity": "sha512-zWftVqfUStDA37wO1ZNDN1qMJOfcxELa8ucHW8W8wBAZY3TK5Nb6deLogCK/IJi/Qljf30dwwuqqv84Qqle9Tw==", "license": "MIT" }, "node_modules/@cspell/dict-en-common-misspellings": { @@ -258,15 +258,15 @@ "license": "CC BY-SA 4.0" }, "node_modules/@cspell/dict-en-gb-mit": { - "version": "3.1.18", - "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.18.tgz", - "integrity": "sha512-AXaMzbaxhSc32MSzKX0cpwT+Thv1vPfxQz1nTly1VHw3wQcwPqVFSqrLOYwa8VNqAPR45583nnhD6iqJ9YESoQ==", + "version": "3.1.22", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.22.tgz", + "integrity": "sha512-xE5Vg6gGdMkZ1Ep6z9SJMMioGkkT1GbxS5Mm0U3Ey1/H68P0G7cJcyiVr1CARxFbLqKE4QUpoV1o6jz1Z5Yl9Q==", "license": "MIT" }, "node_modules/@cspell/dict-filetypes": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.16.tgz", - "integrity": "sha512-SyrtuK2/sx+cr94jOp2/uOAb43ngZEVISUTRj4SR6SfoGULVV1iJS7Drqn7Ul9HJ731QDttwWlOUgcQ+yMRblg==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.18.tgz", + "integrity": "sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==", "license": "MIT" }, "node_modules/@cspell/dict-flutter": { @@ -276,9 +276,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-fonts": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.5.tgz", - "integrity": "sha512-BbpkX10DUX/xzHs6lb7yzDf/LPjwYIBJHJlUXSBXDtK/1HaeS+Wqol4Mlm2+NAgZ7ikIE5DQMViTgBUY3ezNoQ==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.6.tgz", + "integrity": "sha512-aR/0csY01dNb0A1tw/UmN9rKgHruUxsYsvXu6YlSBJFu60s26SKr/k1o4LavpHTQ+lznlYMqAvuxGkE4Flliqw==", "license": "MIT" }, "node_modules/@cspell/dict-fsharp": { @@ -288,9 +288,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-fullstack": { - "version": "3.2.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.8.tgz", - "integrity": "sha512-J6EeoeThvx/DFrcA2rJiCA6vfqwJMbkG0IcXhlsmRZmasIpanmxgt90OEaUazbZahFiuJT8wrhgQ1QgD1MsqBw==", + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.9.tgz", + "integrity": "sha512-diZX+usW5aZ4/b2T0QM/H/Wl9aNMbdODa1Jq0ReBr/jazmNeWjd+PyqeVgzd1joEaHY+SAnjrf/i9CwKd2ZtWQ==", "license": "MIT" }, "node_modules/@cspell/dict-gaming-terms": { @@ -324,9 +324,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-html": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.14.tgz", - "integrity": "sha512-2bf7n+kS92g+cMKV0wr9o/Oq9n8JzU7CcrB96gIh2GHgnF+0xDOqO2W/1KeFAqOfqosoOVE48t+4dnEMkkoJ2Q==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", + "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", "license": "MIT" }, "node_modules/@cspell/dict-html-symbol-entities": { @@ -360,9 +360,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-latex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-5.0.0.tgz", - "integrity": "sha512-HUrIqUVohM6P0+5b7BsdAdb0STIv0aaFBvguI7pLcreljlcX3FSPUxea7ticzNlCNeVrEaiEn/ws9m6rYUeuNw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-5.1.0.tgz", + "integrity": "sha512-qxT4guhysyBt0gzoliXYEBYinkAdEtR2M7goRaUH0a7ltCsoqqAeEV8aXYRIdZGcV77gYSobvu3jJL038tlPAw==", "license": "MIT" }, "node_modules/@cspell/dict-lorem-ipsum": { @@ -384,13 +384,13 @@ "license": "MIT" }, "node_modules/@cspell/dict-markdown": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.14.tgz", - "integrity": "sha512-uLKPNJsUcumMQTsZZgAK9RgDLyQhUz/uvbQTEkvF/Q4XfC1i/BnA8XrOrd0+Vp6+tPOKyA+omI5LRWfMu5K/Lw==", + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.16.tgz", + "integrity": "sha512-976RRqKv6cwhrxdFCQP2DdnBVB86BF57oQtPHy4Zbf4jF/i2Oy29MCrxirnOBalS1W6KQeto7NdfDXRAwkK4PQ==", "license": "MIT", "peerDependencies": { - "@cspell/dict-css": "^4.0.19", - "@cspell/dict-html": "^4.0.14", + "@cspell/dict-css": "^4.1.1", + "@cspell/dict-html": "^4.0.15", "@cspell/dict-html-symbol-entities": "^4.0.5", "@cspell/dict-typescript": "^3.2.3" } @@ -408,9 +408,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-npm": { - "version": "5.2.35", - "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.35.tgz", - "integrity": "sha512-w0VIDUvzHSTt4S9pfvSatApxtCesLMFrDUYD0Wjtw91EBRkB2wVw/RV3q1Ni9Nzpx6pCFpcB7c1xBY8l22cyiQ==", + "version": "5.2.38", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.38.tgz", + "integrity": "sha512-21ucGRPYYhr91C2cDBoMPTrcIOStQv33xOqJB0JLoC5LAs2Sfj9EoPGhGb+gIFVHz6Ia7JQWE2SJsOVFJD1wmg==", "license": "MIT" }, "node_modules/@cspell/dict-php": { @@ -432,9 +432,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-python": { - "version": "4.2.25", - "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.25.tgz", - "integrity": "sha512-hDdN0YhKgpbtZVRjQ2c8jk+n0wQdidAKj1Fk8w7KEHb3YlY5uPJ0mAKJk7AJKPNLOlILoUmN+HAVJz+cfSbWYg==", + "version": "4.2.26", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.26.tgz", + "integrity": "sha512-hbjN6BjlSgZOG2dA2DtvYNGBM5Aq0i0dHaZjMOI9K/9vRicVvKbcCiBSSrR3b+jwjhQL5ff7HwG5xFaaci0GQA==", "license": "MIT", "dependencies": { "@cspell/dict-data-science": "^2.0.13" @@ -447,9 +447,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-ruby": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.0.tgz", - "integrity": "sha512-9PJQB3cfkBULrMLp5kSAcFPpzf8oz9vFN+QYZABhQwWkGbuzCIXSorHrmWSASlx4yejt3brjaWS57zZ/YL5ZQQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.1.tgz", + "integrity": "sha512-LHrp84oEV6q1ZxPPyj4z+FdKyq1XAKYPtmGptrd+uwHbrF/Ns5+fy6gtSi7pS+uc0zk3JdO9w/tPK+8N1/7WUA==", "license": "MIT" }, "node_modules/@cspell/dict-rust": { @@ -471,9 +471,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-software-terms": { - "version": "5.1.22", - "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.1.22.tgz", - "integrity": "sha512-ELi8wqyDAreDpo17Pu45AuKvcrhqPCkGeL+DMuSVxEimBwEqPB+KeQ89DkVap6QDJHl3LCth0pjv8uOgS1dtdQ==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.2.2.tgz", + "integrity": "sha512-0CaYd6TAsKtEoA7tNswm1iptEblTzEe3UG8beG2cpSTHk7afWIVMtJLgXDv0f/Li67Lf3Z1Jf3JeXR7GsJ2TRw==", "license": "MIT" }, "node_modules/@cspell/dict-sql": { @@ -519,52 +519,52 @@ "license": "MIT" }, "node_modules/@cspell/dynamic-import": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-9.7.0.tgz", - "integrity": "sha512-Ws36IYvtS/8IN3x6K9dPLvTmaArodRJmzTn2Rkf2NaTnIYWhRuFzsP3SVVO59NN3fXswAEbmz5DSbVUe8bPZHg==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-10.0.0.tgz", + "integrity": "sha512-fMqu/5Ma1Q5ZCR/Par+Q4pvaTKmx5pKZzQmkwld2hNounVdk2OaIPM9MzpNn6I1mLk5J+wTnIZmfcWNAzNP9aQ==", "license": "MIT", "dependencies": { - "@cspell/url": "9.7.0", + "@cspell/url": "10.0.0", "import-meta-resolve": "^4.2.0" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/@cspell/filetypes": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.7.0.tgz", - "integrity": "sha512-Ln9e/8wGOyTeL3DCCs6kwd18TSpTw3kxsANjTrzLDASrX4cNmAdvc9J5dcIuBHPaqOAnRQxuZbzUlpRh73Y24w==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-10.0.0.tgz", + "integrity": "sha512-UP57j9yrDtlCHpFxc/eGho1m8DP5olfu9KRWwd5fiqL9nMSE2rUJtPzQyvqmDwO5bVZt3B+fTVdo4gxuiqw25A==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/@cspell/rpc": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-9.7.0.tgz", - "integrity": "sha512-VnZ4ABgQeoS4RwofcePkDP7L6tf3Kh5D7LQKoyRM4R6XtfSsYefym6XKaRl3saGtthH5YyjgNJ0Tgdjen4wAAw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-10.0.0.tgz", + "integrity": "sha512-QrpOZMwz2pAjvl6Hky2PauYoMpLCASn3osjn7uKUbgFV70sahyj6tmx4rRgRX7vHu2WQLZev+YsuO4EujiBDOg==", "license": "MIT", "engines": { - "node": ">=20.18" + "node": ">=22.18.0" } }, "node_modules/@cspell/strong-weak-map": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-9.7.0.tgz", - "integrity": "sha512-5xbvDASjklrmy88O6gmGXgYhpByCXqOj5wIgyvwZe2l83T1bE+iOfGI4pGzZJ/mN+qTn1DNKq8BPBPtDgb7Q2Q==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-10.0.0.tgz", + "integrity": "sha512-JRsato0s2IjYdsng+AGL6oAqgZVQgih5aWKdmxs21H6EdhMaoFDmRE5kXm/RT5a6OMdtnzQM9DqeToqBChWIOQ==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/@cspell/url": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@cspell/url/-/url-9.7.0.tgz", - "integrity": "sha512-ZaaBr0pTvNxmyUbIn+nVPXPr383VqJzfUDMWicgTjJIeo2+T2hOq2kNpgpvTIrWtZrsZnSP8oXms1+sKTjcvkw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cspell/url/-/url-10.0.0.tgz", + "integrity": "sha512-q+0pHQ8DbqjemyaOn/mTtBRbCuKDqhnsVbZ6J9zkTsxPgMpccjy0s5oLXwomfrrxMRBH+UcbERwtUmE+SbnoIQ==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/ansi-regex": { @@ -585,15 +585,6 @@ "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", "license": "MIT" }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -621,22 +612,6 @@ "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, - "node_modules/clear-module": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz", - "integrity": "sha512-LWAxzHqdHsAZlPlEyJ2Poz6AIs384mPeqLVCru2p0BrP9G/kVGuhNyZYClLO6cXlnuJjzC8xtsJIuMjKqLXoAw==", - "license": "MIT", - "dependencies": { - "parent-module": "^2.0.0", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -647,50 +622,43 @@ } }, "node_modules/comment-json": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.5.1.tgz", - "integrity": "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", "esprima": "^4.0.1" }, "engines": { "node": ">= 6" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/cspell": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/cspell/-/cspell-9.7.0.tgz", - "integrity": "sha512-ftxOnkd+scAI7RZ1/ksgBZRr0ouC7QRKtPQhD/PbLTKwAM62sSvRhE1bFsuW3VKBn/GilWzTjkJ40WmnDqH5iQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cspell/-/cspell-10.0.0.tgz", + "integrity": "sha512-R25gsSR1SLlcGyw48fwJwp0PjXrVdl7RDO/Dm5+s4DvC1uQSlyiUxsr/8ZtbyC/MPeUJFQN9B4luqLlSm0WelQ==", "license": "MIT", "dependencies": { - "@cspell/cspell-json-reporter": "9.7.0", - "@cspell/cspell-performance-monitor": "9.7.0", - "@cspell/cspell-pipe": "9.7.0", - "@cspell/cspell-types": "9.7.0", - "@cspell/cspell-worker": "9.7.0", - "@cspell/dynamic-import": "9.7.0", - "@cspell/url": "9.7.0", + "@cspell/cspell-json-reporter": "10.0.0", + "@cspell/cspell-performance-monitor": "10.0.0", + "@cspell/cspell-pipe": "10.0.0", + "@cspell/cspell-types": "10.0.0", + "@cspell/cspell-worker": "10.0.0", + "@cspell/dynamic-import": "10.0.0", + "@cspell/url": "10.0.0", "ansi-regex": "^6.2.2", "chalk": "^5.6.2", "chalk-template": "^1.1.2", "commander": "^14.0.3", - "cspell-config-lib": "9.7.0", - "cspell-dictionary": "9.7.0", - "cspell-gitignore": "9.7.0", - "cspell-glob": "9.7.0", - "cspell-io": "9.7.0", - "cspell-lib": "9.7.0", + "cspell-config-lib": "10.0.0", + "cspell-dictionary": "10.0.0", + "cspell-gitignore": "10.0.0", + "cspell-glob": "10.0.0", + "cspell-io": "10.0.0", + "cspell-lib": "10.0.0", "fast-json-stable-stringify": "^2.1.0", - "flatted": "^3.3.3", + "flatted": "^3.4.2", "semver": "^7.7.4", "tinyglobby": "^0.2.15" }, @@ -699,147 +667,146 @@ "cspell-esm": "bin.mjs" }, "engines": { - "node": ">=20.18" + "node": ">=22.18.0" }, "funding": { "url": "https://github.com/streetsidesoftware/cspell?sponsor=1" } }, "node_modules/cspell-config-lib": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-9.7.0.tgz", - "integrity": "sha512-pguh8A3+bSJ1OOrKCiQan8bvaaY125de76OEFz7q1Pq309lIcDrkoL/W4aYbso/NjrXaIw6OjkgPMGRBI/IgGg==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-10.0.0.tgz", + "integrity": "sha512-HWK7SRnJ3N/kOThw/uzmXmQYCzBxu58Jkq2hHyte1voDl118BeNFoaNRWMpYdHbBi3kCj8gaZu8wGtm+Zmdhxw==", "license": "MIT", "dependencies": { - "@cspell/cspell-types": "9.7.0", - "comment-json": "^4.5.1", - "smol-toml": "^1.6.0", - "yaml": "^2.8.2" + "@cspell/cspell-types": "10.0.0", + "comment-json": "^4.6.2", + "smol-toml": "^1.6.1", + "yaml": "^2.8.3" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/cspell-dictionary": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-9.7.0.tgz", - "integrity": "sha512-k/Wz0so32+0QEqQe21V9m4BNXM5ZN6lz3Ix/jLCbMxFIPl6wT711ftjOWIEMFhvUOP0TWXsbzcuE9mKtS5mTig==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-10.0.0.tgz", + "integrity": "sha512-KubSoEAJO+77KPSSWjoLCz0+MIWVNq3joGTSyxucAZrBSJD64Y1O4BHHr1aj6XHIZwXhWWNScQlrQR3OcIulng==", "license": "MIT", "dependencies": { - "@cspell/cspell-performance-monitor": "9.7.0", - "@cspell/cspell-pipe": "9.7.0", - "@cspell/cspell-types": "9.7.0", - "cspell-trie-lib": "9.7.0", + "@cspell/cspell-performance-monitor": "10.0.0", + "@cspell/cspell-pipe": "10.0.0", + "@cspell/cspell-types": "10.0.0", + "cspell-trie-lib": "10.0.0", "fast-equals": "^6.0.0" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/cspell-gitignore": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-9.7.0.tgz", - "integrity": "sha512-MtoYuH4ah4K6RrmaF834npMcRsTKw0658mC6yvmBacUQOmwB/olqyuxF3fxtbb55HDb7cXDQ35t1XuwwGEQeZw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-10.0.0.tgz", + "integrity": "sha512-55XLH9Y52eR7QgyV28Uaw8V9cN1YZ3PQIyrN9YBR4ndQNBKJxO9+jX1nwSspwnccCZiE/N+GGxFzRBb75JDSCw==", "license": "MIT", "dependencies": { - "@cspell/url": "9.7.0", - "cspell-glob": "9.7.0", - "cspell-io": "9.7.0" + "@cspell/url": "10.0.0", + "cspell-glob": "10.0.0", + "cspell-io": "10.0.0" }, "bin": { "cspell-gitignore": "bin.mjs" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/cspell-glob": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-9.7.0.tgz", - "integrity": "sha512-LUeAoEsoCJ+7E3TnUmWBscpVQOmdwBejMlFn0JkXy6LQzxrybxXBKf65RSdIv1o5QtrhQIMa358xXYQG0sv/tA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-10.0.0.tgz", + "integrity": "sha512-bXS35fMcA9X7GEkfnWBfoPd/vTnxxfXW+YHt6tWxu5fejfs00qUbjWp1oLC9FxRaXWxIkfsYp2mi1k1jYl4RVw==", "license": "MIT", "dependencies": { - "@cspell/url": "9.7.0", - "picomatch": "^4.0.3" + "@cspell/url": "10.0.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/cspell-grammar": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-9.7.0.tgz", - "integrity": "sha512-oEYME+7MJztfVY1C06aGcJgEYyqBS/v/ETkQGPzf/c6ObSAPRcUbVtsXZgnR72Gru9aBckc70xJcD6bELdoWCA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-10.0.0.tgz", + "integrity": "sha512-49udtYzkcCYEIDJbFOb4IwiAJebOYZnYvG6o6Ep19Tq0Xwjk7i4vxUprNiFNDCWFbcbJRPE9cpwQUVwp5WFGLw==", "license": "MIT", "dependencies": { - "@cspell/cspell-pipe": "9.7.0", - "@cspell/cspell-types": "9.7.0" + "@cspell/cspell-pipe": "10.0.0", + "@cspell/cspell-types": "10.0.0" }, "bin": { "cspell-grammar": "bin.mjs" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/cspell-io": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-9.7.0.tgz", - "integrity": "sha512-V7x0JHAUCcJPRCH8c0MQkkaKmZD2yotxVyrNEx2SZTpvnKrYscLEnUUTWnGJIIf9znzISqw116PLnYu2c+zd6Q==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-10.0.0.tgz", + "integrity": "sha512-NQCAUhx9DwKApxPuFl7EK1K1XSaQEAPld45yjjwv93xF8rJkEGkgzOwjbqafwAD20eKYv1a7oj/9EC0S5jETSw==", "license": "MIT", "dependencies": { - "@cspell/cspell-service-bus": "9.7.0", - "@cspell/url": "9.7.0" + "@cspell/cspell-service-bus": "10.0.0", + "@cspell/url": "10.0.0" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/cspell-lib": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-9.7.0.tgz", - "integrity": "sha512-aTx/aLRpnuY1RJnYAu+A8PXfm1oIUdvAQ4W9E66bTgp1LWI+2G2++UtaPxRIgI0olxE9vcXqUnKpjOpO+5W9bQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-10.0.0.tgz", + "integrity": "sha512-PowW6JEjuv/F2aFEirZvBxpzHdchOnpsUJbeIcFcai0++taLTbHQObROBEBf7e0S8DnHpVD5TZkqrTME5e44wg==", "license": "MIT", "dependencies": { - "@cspell/cspell-bundled-dicts": "9.7.0", - "@cspell/cspell-performance-monitor": "9.7.0", - "@cspell/cspell-pipe": "9.7.0", - "@cspell/cspell-resolver": "9.7.0", - "@cspell/cspell-types": "9.7.0", - "@cspell/dynamic-import": "9.7.0", - "@cspell/filetypes": "9.7.0", - "@cspell/rpc": "9.7.0", - "@cspell/strong-weak-map": "9.7.0", - "@cspell/url": "9.7.0", - "clear-module": "^4.1.2", - "cspell-config-lib": "9.7.0", - "cspell-dictionary": "9.7.0", - "cspell-glob": "9.7.0", - "cspell-grammar": "9.7.0", - "cspell-io": "9.7.0", - "cspell-trie-lib": "9.7.0", + "@cspell/cspell-bundled-dicts": "10.0.0", + "@cspell/cspell-performance-monitor": "10.0.0", + "@cspell/cspell-pipe": "10.0.0", + "@cspell/cspell-resolver": "10.0.0", + "@cspell/cspell-types": "10.0.0", + "@cspell/dynamic-import": "10.0.0", + "@cspell/filetypes": "10.0.0", + "@cspell/rpc": "10.0.0", + "@cspell/strong-weak-map": "10.0.0", + "@cspell/url": "10.0.0", + "cspell-config-lib": "10.0.0", + "cspell-dictionary": "10.0.0", + "cspell-glob": "10.0.0", + "cspell-grammar": "10.0.0", + "cspell-io": "10.0.0", + "cspell-trie-lib": "10.0.0", "env-paths": "^4.0.0", "gensequence": "^8.0.8", - "import-fresh": "^3.3.1", + "import-fresh": "^4.0.0", "resolve-from": "^5.0.0", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0", "xdg-basedir": "^5.1.0" }, "engines": { - "node": ">=20" + "node": ">=22.18.0" } }, "node_modules/cspell-trie-lib": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-9.7.0.tgz", - "integrity": "sha512-a2YqmcraL3g6I/4gY7SYWEZfP73oLluUtxO7wxompk/kOG2K1FUXyQfZXaaR7HxVv10axT1+NrjhOmXpfbI6LA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-10.0.0.tgz", + "integrity": "sha512-R8qrMx10E/bm3Lecslwxn9XYo5NzSRK1rtandEX5n9UmEYHoBXjZELkg5+TOnV8VgrVaJSK57XtcGrbKp/4kSg==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22.18.0" }, "peerDependencies": { - "@cspell/cspell-types": "9.7.0" + "@cspell/cspell-types": "10.0.0" } }, "node_modules/env-paths": { @@ -933,42 +900,17 @@ } }, "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-4.0.0.tgz", + "integrity": "sha512-Fpi660c7VPDM3fPKYovStd9IP1CPOikf6v/dGxJJMmHPcwYQIMJ4W7kO1avBYEpMqkCh+Dx3Ln6H7VYqgztLjw==", "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, "engines": { - "node": ">=6" + "node": ">=22.15" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-fresh/node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -1000,18 +942,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-2.0.0.tgz", - "integrity": "sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==", - "license": "MIT", - "dependencies": { - "callsites": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", diff --git a/package.json b/package.json index aa3040c15..a158a38bf 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,6 @@ "cspell": "cspell --config ./cSpell.json ./docs/**/*.md --no-progress --no-summary" }, "dependencies": { - "cspell": "^9.7.0" + "cspell": "^10.0.0" } } From ca64123edf2656453d9e04481d7dd17de27b12ec Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 12 Apr 2026 14:55:36 +0200 Subject: [PATCH 07/43] Fail with descriptive errors for selection rules on value-semantic types (#3187) --- Src/FluentAssertions/Common/MemberPath.cs | 6 +- .../CollectionMemberSelectionRuleDecorator.cs | 15 +- .../ExcludeMemberByPathSelectionRule.cs | 11 +- .../Selection/IPathBasedSelectionRule.cs | 16 ++ .../IncludeMemberByPathSelectionRule.cs | 15 +- .../SelectMemberByPathSelectionRule.cs | 64 +++++- .../Steps/ValueTypeEquivalencyStep.cs | 169 ++++++++++++-- .../SelectionRulesSpecs.Excluding.cs | 208 ++++++++++++++++++ .../SelectionRulesSpecs.Including.cs | 18 ++ docs/_pages/releases.md | 1 + 10 files changed, 471 insertions(+), 52 deletions(-) create mode 100644 Src/FluentAssertions/Equivalency/Selection/IPathBasedSelectionRule.cs diff --git a/Src/FluentAssertions/Common/MemberPath.cs b/Src/FluentAssertions/Common/MemberPath.cs index bbe8fb29d..7121d9226 100644 --- a/Src/FluentAssertions/Common/MemberPath.cs +++ b/Src/FluentAssertions/Common/MemberPath.cs @@ -70,7 +70,11 @@ public bool IsSameAs(MemberPath candidate) return false; } - private bool IsParentOf(MemberPath candidate) + /// + /// Determines whether the current path is the prefix of + /// (i.e., the current path "owns" the candidate), treating numeric indices and wildcards as interchangeable. + /// + public bool IsParentOf(MemberPath candidate) { string[] candidateSegments = candidate.Segments; diff --git a/Src/FluentAssertions/Equivalency/Selection/CollectionMemberSelectionRuleDecorator.cs b/Src/FluentAssertions/Equivalency/Selection/CollectionMemberSelectionRuleDecorator.cs index cfcdb8d5d..8fab43125 100644 --- a/Src/FluentAssertions/Equivalency/Selection/CollectionMemberSelectionRuleDecorator.cs +++ b/Src/FluentAssertions/Equivalency/Selection/CollectionMemberSelectionRuleDecorator.cs @@ -2,15 +2,8 @@ namespace FluentAssertions.Equivalency.Selection; -internal class CollectionMemberSelectionRuleDecorator : IMemberSelectionRule +internal class CollectionMemberSelectionRuleDecorator(IMemberSelectionRule selectionRule) : IPathBasedSelectionRule { - private readonly IMemberSelectionRule selectionRule; - - public CollectionMemberSelectionRuleDecorator(IMemberSelectionRule selectionRule) - { - this.selectionRule = selectionRule; - } - public bool IncludesMembers => selectionRule.IncludesMembers; public IEnumerable SelectMembers(INode currentNode, IEnumerable selectedMembers, @@ -19,6 +12,12 @@ public IEnumerable SelectMembers(INode currentNode, IEnumerable /// Selection rule that removes a particular property from the structural comparison. /// -internal class ExcludeMemberByPathSelectionRule : SelectMemberByPathSelectionRule +internal class ExcludeMemberByPathSelectionRule(MemberPath pathToExclude) : SelectMemberByPathSelectionRule { - private MemberPath memberToExclude; - - public ExcludeMemberByPathSelectionRule(MemberPath pathToExclude) - { - memberToExclude = pathToExclude; - } + private MemberPath memberToExclude = pathToExclude; protected override void AddOrRemoveMembersFrom(List selectedMembers, INode parent, string parentPath, MemberSelectionContext context) @@ -27,6 +22,8 @@ public void AppendPath(MemberPath nextPath) memberToExclude = memberToExclude.AsParentCollectionOf(nextPath); } + protected override MemberPath MemberPath => memberToExclude; + public MemberPath CurrentPath => memberToExclude; public override string ToString() diff --git a/Src/FluentAssertions/Equivalency/Selection/IPathBasedSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/IPathBasedSelectionRule.cs new file mode 100644 index 000000000..f7ded83c0 --- /dev/null +++ b/Src/FluentAssertions/Equivalency/Selection/IPathBasedSelectionRule.cs @@ -0,0 +1,16 @@ +namespace FluentAssertions.Equivalency.Selection; + +/// +/// Represents a selection rule whose effect is determined by a configured member path. +/// This allows callers to ask whether the rule targets any members within the subtree rooted at +/// a given , even when the rule is wrapped by decorators such as the +/// collection-member options decorator. +/// +internal interface IPathBasedSelectionRule : IMemberSelectionRule +{ + /// + /// Returns true when this rule targets at least one member of + /// or one of its descendants. + /// + bool SelectsMembersOf(INode currentNode); +} diff --git a/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs index 4ed440b5a..79775410b 100644 --- a/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs @@ -8,17 +8,12 @@ namespace FluentAssertions.Equivalency.Selection; /// /// Selection rule that includes a particular property in the structural comparison. /// -internal class IncludeMemberByPathSelectionRule : SelectMemberByPathSelectionRule +internal class IncludeMemberByPathSelectionRule(MemberPath pathToInclude) : SelectMemberByPathSelectionRule { - private readonly MemberPath memberToInclude; - - public IncludeMemberByPathSelectionRule(MemberPath pathToInclude) - { - memberToInclude = pathToInclude; - } - public override bool IncludesMembers => true; + protected override MemberPath MemberPath => pathToInclude; + protected override void AddOrRemoveMembersFrom(List selectedMembers, INode parent, string parentPath, MemberSelectionContext context) { @@ -26,7 +21,7 @@ protected override void AddOrRemoveMembersFrom(List selectedMembers, IN { var memberPath = new MemberPath(context.Type, memberInfo.DeclaringType, parentPath.Combine(memberInfo.Name)); - if (memberToInclude.IsSameAs(memberPath) || memberToInclude.IsParentOrChildOf(memberPath)) + if (pathToInclude.IsSameAs(memberPath) || pathToInclude.IsParentOrChildOf(memberPath)) { selectedMembers.Add(MemberFactory.Create(memberInfo, parent)); } @@ -35,6 +30,6 @@ protected override void AddOrRemoveMembersFrom(List selectedMembers, IN public override string ToString() { - return "Include member root." + memberToInclude; + return "Include member root." + pathToInclude; } } diff --git a/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs index ac37b3183..2f0fae01e 100644 --- a/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs @@ -1,36 +1,82 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using FluentAssertions.Common; namespace FluentAssertions.Equivalency.Selection; -internal abstract class SelectMemberByPathSelectionRule : IMemberSelectionRule +internal abstract class SelectMemberByPathSelectionRule : IPathBasedSelectionRule { + private static readonly Regex LeadingCollectionIndexRegex = new(@"^\[[0-9]+]\.?"); + public virtual bool IncludesMembers => false; + protected abstract MemberPath MemberPath { get; } + public IEnumerable SelectMembers(INode currentNode, IEnumerable selectedMembers, MemberSelectionContext context) { - var currentPath = RemoveRootIndexQualifier(currentNode.Expectation.PathAndName); var members = selectedMembers.ToList(); - AddOrRemoveMembersFrom(members, currentNode, currentPath, context); + AddOrRemoveMembersFrom(members, currentNode, GetPathRelativeToSelectionRoot(currentNode), context); return members; } + /// + /// Returns true if this rule would select members within the subtree rooted at + /// , meaning the rule path targets any member at or below the current node. + /// + public bool SelectsMembersOf(INode currentNode) + { + if (currentNode.IsRoot) + { + return !MemberPath.ToString().IsNullOrEmpty(); + } + + string currentPath = GetPathRelativeToSelectionRoot(currentNode); + + if (string.IsNullOrEmpty(currentPath)) + { + return !MemberPath.ToString().IsNullOrEmpty(); + } + + // Compare normalized path segments rather than raw strings so collection rules like "Items[].Name" + // still match the concrete node path "Items[0].Name", and so paths built through + // MemberPath.AsParentCollectionOf are interpreted consistently. + return new MemberPath(currentPath).IsParentOf(MemberPath); + } + protected abstract void AddOrRemoveMembersFrom(List selectedMembers, INode parent, string parentPath, MemberSelectionContext context); - private static string RemoveRootIndexQualifier(string path) + /// + /// Returns the path used for member-selection matching at . + /// For items inside a root collection, Fluent Assertions exposes paths such as [0].Name, but + /// selection rules are expressed relative to the collection item type (for example Name). + /// This method removes that root-item index so both sides use the same coordinate system. + /// + private static string GetPathRelativeToSelectionRoot(INode currentNode) { - Match match = new Regex(@"^\[[0-9]+]").Match(path); - - if (match.Success) + if (currentNode.IsRoot) { - path = path.Substring(match.Length); + return string.Empty; } - return path; + string expectationPath = currentNode.Expectation.PathAndName; + return RemoveLeadingCollectionIndex(expectationPath); + } + + /// + /// Removes only the leading root-collection index from , such as turning + /// [0] into an empty path and [0].Name into Name. + /// Nested collection indices are intentionally left in place; they are handled later by + /// comparison, which treats [] and concrete indices as equivalent. + /// + private static string RemoveLeadingCollectionIndex(string path) + { + Match match = LeadingCollectionIndexRegex.Match(path); + return match.Success ? path.Substring(match.Length) : path; } } diff --git a/Src/FluentAssertions/Equivalency/Steps/ValueTypeEquivalencyStep.cs b/Src/FluentAssertions/Equivalency/Steps/ValueTypeEquivalencyStep.cs index 4c5bb459d..1ddaa1228 100644 --- a/Src/FluentAssertions/Equivalency/Steps/ValueTypeEquivalencyStep.cs +++ b/Src/FluentAssertions/Equivalency/Steps/ValueTypeEquivalencyStep.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions.Equivalency.Selection; using FluentAssertions.Execution; namespace FluentAssertions.Equivalency.Steps; @@ -12,32 +15,164 @@ public class ValueTypeEquivalencyStep : IEquivalencyStep public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationContext context, IValidateChildNodeEquivalency valueChildNodes) { - Type expectationType = comparands.GetExpectedType(context.Options); - EqualityStrategy strategy = context.Options.GetEqualityStrategy(expectationType); + var options = context.Options; + Type expectationType = comparands.GetExpectedType(options); + EqualityStrategy strategy = options.GetEqualityStrategy(expectationType); - bool canHandle = strategy is EqualityStrategy.Equals or EqualityStrategy.ForceEquals; + if (strategy is not (EqualityStrategy.Equals or EqualityStrategy.ForceEquals)) + { + return EquivalencyResult.ContinueWithNext; + } - if (canHandle) + if (ReportConflictIfAny(context, comparands, options, expectationType, strategy)) { - context.Tracer.WriteLine(member => - { - string strategyName = strategy == EqualityStrategy.Equals - ? $"{expectationType} overrides Equals" - : "we are forced to use Equals"; + return EquivalencyResult.EquivalencyProven; + } + + ApplyValueSemantics(comparands, context, expectationType, strategy); + return EquivalencyResult.EquivalencyProven; + } - return $"Treating {member.Expectation.Description} as a value type because {strategyName}."; - }); + /// + /// Checks for any selection rule that targets members of the current node, which would be silently + /// ignored under value-semantic comparison. Reports the conflict and returns true if one is found. + /// + private static bool ReportConflictIfAny( + IEquivalencyValidationContext context, + Comparands comparands, + IEquivalencyOptions options, + Type expectationType, + EqualityStrategy strategy) + { + // Path-based rules can be checked cheaply by comparing path strings (also handles deep paths like o.Child.Text). + IMemberSelectionRule conflictingRule = options.SelectionRules + .OfType() + .FirstOrDefault(rule => rule.SelectsMembersOf(context.CurrentNode)); - AssertionChain.GetOrCreate() - .For(context) - .ReuseOnce(); + // Non-path rules (based on predicates) require evaluating them against the actual member list + conflictingRule ??= FindConflictingNonPathRule(context.CurrentNode, comparands, options); - comparands.Subject.Should().Be(comparands.Expectation, context.Reason.FormattedMessage, context.Reason.Arguments); + if (conflictingRule is not null) + { + ReportConflict(context, expectationType, conflictingRule, strategy); + } - return EquivalencyResult.EquivalencyProven; + return conflictingRule is not null; + } + + /// + /// Finds the first non-path selection rule that would modify the member list of , + /// indicating it would be silently ignored due to value-semantic comparison. + /// + private static IMemberSelectionRule FindConflictingNonPathRule( + INode currentNode, Comparands comparands, IEquivalencyOptions options) + { + var selectionContext = new MemberSelectionContext(comparands.CompileTimeType, comparands.RuntimeType, options); + IList allMembers = GetAllMembers(currentNode, selectionContext); + + // If the type has no accessible members, no selection rule can meaningfully conflict. + if (allMembers.Count == 0) + { + return null; } - return EquivalencyResult.ContinueWithNext; + return options.SelectionRules + .Where(rule => !IsInfrastructureRule(rule) && rule is not IPathBasedSelectionRule) + .FirstOrDefault(rule => RuleAffectsMembers(rule, allMembers, GetFilteredMembers(rule, currentNode, allMembers, selectionContext))); + } + + /// + /// Applies in isolation to determine whether it modifies the member list. + /// Inclusion rules are run from an empty starting set; exclusion rules are run on the full member list. + /// + private static IList GetFilteredMembers( + IMemberSelectionRule rule, INode currentNode, IList allMembers, MemberSelectionContext context) + { + IEnumerable seed = rule.IncludesMembers ? [] : allMembers; + return rule.SelectMembers(currentNode, seed, context).ToList(); + } + + /// Gets all properties and fields of the current node's type, respecting the configured visibility. + private static IList GetAllMembers(INode currentNode, MemberSelectionContext context) + { + IEnumerable members = new AllPropertiesSelectionRule().SelectMembers(currentNode, [], context); + return new AllFieldsSelectionRule().SelectMembers(currentNode, members, context).ToList(); + } + + /// + /// Returns true if actually affects the member set of the current value type. + /// For inclusion rules, a conflict exists only when the rule selects at least one member of this type, + /// meaning the user intended to compare this value type member-by-member. + /// For exclusion rules, a conflict exists when the rule removes at least one member from the full set. + /// + private static bool RuleAffectsMembers(IMemberSelectionRule rule, IList allMembers, IList filteredMembers) => + rule.IncludesMembers + ? filteredMembers.Count > 0 + : MemberSetChanged(allMembers, filteredMembers); + + /// Returns true if represents a different set of members than . + private static bool MemberSetChanged(IList before, IList after) => + before.Count != after.Count || before.Except(after).Any(); + + /// Returns true for rules that are part of the default selection pipeline, not user-configured. + private static bool IsInfrastructureRule(IMemberSelectionRule rule) => + rule is AllPropertiesSelectionRule or AllFieldsSelectionRule or ExcludeNonBrowsableMembersRule; + + /// Reports an assertion failure for a selection rule that conflicts with value semantics. + private static void ReportConflict( + IEquivalencyValidationContext context, + Type expectationType, + IMemberSelectionRule conflictingRule, + EqualityStrategy strategy) + { + Reason reason = context.Reason; + bool isGeneric = expectationType.IsGenericType; + + string cause = strategy == EqualityStrategy.ForceEquals + ? $"{expectationType} is compared by value (because ComparingByValue was configured), " + : $"{expectationType} is compared by value (because it overrides Equals), "; + + string suggestion = strategy == EqualityStrategy.ForceEquals + ? "Either remove the ComparingByValue configuration, " + : isGeneric + ? "Either call the ComparingByMembers(Type) overload to force member-wise comparison, " + : $"Either call ComparingByMembers<{expectationType.Name}>() to force member-wise comparison, "; + + string message = + cause + + $"so the {conflictingRule} selection rule does not apply. " + + suggestion + + "or remove the selection rule." + + reason.FormattedMessage; + + AssertionChain.GetOrCreate().For(context).FailWith(message, reason.Arguments); + } + + /// + /// Traces the value-semantic comparison strategy and performs the equality assertion. + /// + private static void ApplyValueSemantics( + Comparands comparands, + IEquivalencyValidationContext context, + Type expectationType, + EqualityStrategy strategy) + { + context.Tracer.WriteLine(member => + { + string strategyName = strategy == EqualityStrategy.Equals + ? $"{expectationType} overrides Equals" + : "we are forced to use Equals"; + + return $"Treating {member.Expectation.Description} as a value type because {strategyName}."; + }); + + var reason = context.Reason; + + AssertionChain.GetOrCreate() + .For(context) + .ReuseOnce(); + + comparands.Subject.Should().Be(comparands.Expectation, reason.FormattedMessage, reason.Arguments); } } diff --git a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs index 44b8686a8..21a676ba3 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs @@ -1259,5 +1259,213 @@ public void Cannot_provide_null_as_a_property_name() act.Should().Throw() .WithMessage("*Member names cannot be null*"); } + + [Fact] + public void Excluding_a_member_by_path_on_a_type_with_value_semantics_fails_with_a_descriptive_error() + { + // Arrange + var actual = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" }; + var expected = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "y" }; + + // Act + Action act = () => actual.Should().BeEquivalentTo(expected, + opt => opt.Excluding(o => o.NestedProperty)); + + // Assert + act.Should().Throw() + .WithMessage( + "*ClassWithValueSemanticsOnSingleProperty*compared by value*overrides Equals*" + + "*ComparingByMembers*"); + } + + [Fact] + public void Excluding_a_member_by_path_when_forcing_value_semantics_explicitly_fails_with_a_descriptive_error() + { + // Arrange + var actual = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" }; + var expected = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "y" }; + + // Act + Action act = () => actual.Should().BeEquivalentTo(expected, + opt => opt.Excluding(o => o.NestedProperty).ComparingByValue()); + + // Assert + act.Should().Throw() + .WithMessage( + "*ClassWithValueSemanticsOnSingleProperty*compared by value*ComparingByValue was configured*" + + "*remove the ComparingByValue configuration*"); + } + + [Fact] + public void Including_a_member_by_path_when_forcing_value_semantics_explicitly_fails_with_a_descriptive_error() + { + // Arrange + var actual = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" }; + var expected = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "y" }; + + // Act + Action act = () => actual.Should().BeEquivalentTo(expected, + opt => opt.Including(o => o.Key).ComparingByValue()); + + // Assert + act.Should().Throw() + .WithMessage( + "*ClassWithValueSemanticsOnSingleProperty*compared by value*ComparingByValue was configured*" + + "*remove the ComparingByValue configuration*"); + } + + [Fact] + public void Excluding_a_member_by_predicate_when_forcing_value_semantics_explicitly_fails_with_a_descriptive_error() + { + // Arrange + var actual = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" }; + var expected = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "y" }; + + // Act + Action act = () => actual.Should().BeEquivalentTo(expected, + opt => opt + .Excluding(m => m.Name == nameof(ClassWithValueSemanticsOnSingleProperty.NestedProperty)) + .ComparingByValue()); + + // Assert + act.Should().Throw() + .WithMessage( + "*ClassWithValueSemanticsOnSingleProperty*compared by value*ComparingByValue was configured*" + + "*remove the ComparingByValue configuration*"); + } + + [Fact] + public void Excluding_a_member_by_path_and_then_forcing_member_comparison_does_not_fail() + { + // Arrange + var actual = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" }; + var expected = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "y" }; + + // Act / Assert + actual.Should().BeEquivalentTo(expected, opt => opt + .ComparingByMembers() + .Excluding(o => o.NestedProperty)); + } + + [Fact] + public void Excluding_a_nested_member_by_path_on_a_type_with_value_semantics_fails_with_a_descriptive_error() + { + // Arrange + var actual = new ClassWithValueSemanticsAndNestedObject { Key = "same", Child = new NestedObjectWithProperty { Text = "x" } }; + var expected = new ClassWithValueSemanticsAndNestedObject { Key = "same", Child = new NestedObjectWithProperty { Text = "y" } }; + + // Act + Action act = () => actual.Should().BeEquivalentTo(expected, + opt => opt.Excluding(o => o.Child.Text)); + + // Assert - multi-segment path at root should also be detected as conflicting + act.Should().Throw() + .WithMessage( + "*ClassWithValueSemanticsAndNestedObject*compared by value*overrides Equals*" + + "*ComparingByMembers*"); + } + + [Fact] + public void Excluding_a_member_by_predicate_on_a_type_with_value_semantics_fails_with_a_descriptive_error() + { + // Arrange + var actual = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" }; + var expected = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "y" }; + + // Act + Action act = () => actual.Should().BeEquivalentTo(expected, + opt => opt.Excluding(m => m.Name == nameof(ClassWithValueSemanticsOnSingleProperty.NestedProperty))); + + // Assert + act.Should().Throw() + .WithMessage( + "*ClassWithValueSemanticsOnSingleProperty*compared by value*overrides Equals*" + + "*ComparingByMembers*"); + } + + [Fact] + public void Including_members_by_predicate_on_a_type_with_value_semantics_fails_with_a_descriptive_error() + { + // Arrange + var actual = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" }; + var expected = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "y" }; + + // Act + Action act = () => actual.Should().BeEquivalentTo(expected, + opt => opt.Including(m => m.Name == nameof(ClassWithValueSemanticsOnSingleProperty.Key))); + + // Assert + act.Should().Throw() + .WithMessage( + "*ClassWithValueSemanticsOnSingleProperty*compared by value*overrides Equals*" + + "*ComparingByMembers*"); + } + + [Fact] + public void Excluding_a_member_of_a_collection_element_with_value_semantics_via_For_and_Exclude_fails_with_a_descriptive_error() + { + // Arrange + var actual = new ClassWithCollectionOfValueSemantics + { + Items = [new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" }] + }; + + var expected = new ClassWithCollectionOfValueSemantics + { + Items = [new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "y" }] + }; + + // Act + // .For(o => o.Items).Exclude(c => c.NestedProperty) creates path "Items[].NestedProperty" (wildcard), + // which should be detected as conflicting when processing the value-semantic item at Items[0]. + Action act = () => actual.Should().BeEquivalentTo(expected, + opt => opt.For(o => o.Items).Exclude(c => c.NestedProperty)); + + // Assert + act.Should().Throw() + .WithMessage( + "*ClassWithValueSemanticsOnSingleProperty*compared by value*overrides Equals*"); + } + } +} + +public class ClassWithCollectionOfValueSemantics +{ + public List Items { get; set; } +} + +public class ClassWithValueSemanticsAndNestedObject +{ + public string Key { get; set; } + + public NestedObjectWithProperty Child { get; set; } + + protected bool Equals(ClassWithValueSemanticsAndNestedObject other) => Key == other.Key; + + public override bool Equals(object obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((ClassWithValueSemanticsAndNestedObject)obj); } + + public override int GetHashCode() => Key?.GetHashCode() ?? 0; +} + +public class NestedObjectWithProperty +{ + public string Text { get; set; } } diff --git a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Including.cs b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Including.cs index c43e529ec..eea424bc1 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Including.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Including.cs @@ -506,5 +506,23 @@ public void An_anonymous_object_in_combination_with_exclude_selects_nested_field .Match("*Expected*subject.NestedField.FieldB*").And .NotMatch("*Expected*FieldC*FieldD*FieldE*"); } + + [Fact] + public void Including_a_member_by_path_on_a_type_with_value_semantics_fails_with_a_descriptive_error() + { + // Arrange + var actual = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" }; + var expected = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "y" }; + + // Act + Action act = () => actual.Should().BeEquivalentTo(expected, + opt => opt.Including(o => o.Key)); + + // Assert + act.Should().Throw() + .WithMessage( + "*ClassWithValueSemanticsOnSingleProperty*compared by value*overrides Equals*" + + "*ComparingByMembers*"); + } } } diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index f041c2a8c..23c61839c 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -25,6 +25,7 @@ sidebar: ### Fixes * Fixed a formatting exception when comparing strings containing braces - [#3151](https://github.com/fluentassertions/fluentassertions/pull/3151) +* Path-based `Excluding()` and `Including()` rules on types that use value semantics (i.e. override `Equals`) now fail with a descriptive error instead of being silently ignored - [#3187](https://github.com/fluentassertions/fluentassertions/pull/3187) ## 8.8.0 From faedd12c82f80502e81df2fcf46ac8290dc6697e Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Wed, 8 Apr 2026 21:44:16 +0200 Subject: [PATCH 08/43] Replace permutation search with greedy algorithm for large unordered collections Replace the O(n! * n) permutation search in FindClosestMismatches with a two-strategy approach: - For n <= 8 items remaining, keep the exact permutation search (globally optimal; factorial(8) = 40,320, well within cost). - For n > 8, use a greedy O(n^2 log n) assignment that sorts all n^2 (expectation, subject) pairs by failure count and picks the closest unassigned pair first. Ties are broken by expectation index then subject index to preserve deterministic, natural-order error messages. Also extract ReferentialComparer to its own file and bump Reflectify to 1.9.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LooselyOrderedEquivalencyStrategy.cs | 151 ++++++++++++------ .../Equivalency/Steps/ReferentialComparer.cs | 28 ++++ Src/FluentAssertions/FluentAssertions.csproj | 2 +- Tests/Benchmarks/Program.cs | 2 +- 4 files changed, 134 insertions(+), 49 deletions(-) create mode 100644 Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs diff --git a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs index 0f1512d03..58a16f238 100644 --- a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs +++ b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Runtime.CompilerServices; using FluentAssertions.Common; using FluentAssertions.Equivalency.Tracing; using FluentAssertions.Execution; @@ -18,6 +17,7 @@ internal class LooselyOrderedEquivalencyStrategy( private const int MaximumFailuresToReport = 10; private readonly Tracer tracer = context.Tracer; + private Dictionary<(object Subject, object Expectation, int ExpectationIndex), string[]> failuresCache = new(); public void FindAndRemoveMatches(List subjects, List expectations) @@ -67,7 +67,6 @@ private bool StrictlyMatchAgainst(List remainingSubjects, TExpectation e $"Comparing subject at {member.Subject}[{index}] with the expectation at {member.Expectation}[{expectationIndex}]"); string[] failures = TryToMatch(expectation, subject, expectationIndex); - if (failures.Length == 0) { tracer.WriteLine(_ => "It's a match"); @@ -137,48 +136,130 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, List remainingSubjects, IndexedItemCollection expectationsWithIndexes, Func getFailures) { - var bestScore = int.MaxValue; - List<(IndexedItem ExpectationWithIndex, object, string[] Failures)> bestSet = null; + // For small collections, use exact permutation search to find the globally optimal assignment. + // factorial(8) = 40,320 which is well within reason. + const int maxSizeForExactSearch = 8; - const int maxPermutations = 200_000; - int seen = 0; + return remainingSubjects.Count <= maxSizeForExactSearch ? + FindClosestMismatchesByPermutation(remainingSubjects, expectationsWithIndexes, getFailures) : + FindClosestMismatchesByGreedyAssignment(remainingSubjects, expectationsWithIndexes, getFailures); + } + + /// + /// Finds the best assignment by exhaustively trying all permutations. Only suitable for small collections. + /// + private static IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByPermutation( + List remainingSubjects, IndexedItemCollection expectationsWithIndexes, + Func getFailures) + { + var bestScore = int.MaxValue; + IReadOnlyList bestAssignment = null; foreach (IReadOnlyList assignment in remainingSubjects.Permute()) { - if (++seen > maxPermutations) - { - break; - } - int score = 0; - var currentSet = new List<(IndexedItem ExpectationWithIndex, object, string[] Failures)>(); + bool tooHigh = false; for (int index = 0; index < expectationsWithIndexes.Count && index < assignment.Count; index++) { IndexedItem expectationWithIndex = expectationsWithIndexes[index]; - - string[] failures = getFailures(expectationWithIndex.Item, assignment[index], expectationWithIndex.Index); - - int distance = failures.Length; - score += distance; + score += getFailures(expectationWithIndex.Item, assignment[index], expectationWithIndex.Index).Length; if (score >= bestScore) { - // No need to continue as we already have a better matching set + tooHigh = true; break; } - - currentSet.Add((expectationWithIndex, assignment[index], failures)); } - if (score < bestScore) + if (!tooHigh && score < bestScore) { bestScore = score; - bestSet = currentSet; + bestAssignment = assignment; + } + } + + if (bestAssignment is null) + { + return Array.Empty<(IndexedItem, object, string[])>(); + } + + int pairCount = Math.Min(expectationsWithIndexes.Count, bestAssignment.Count); + var result = new List<(IndexedItem, object, string[])>(pairCount); + + for (int index = 0; index < pairCount; index++) + { + IndexedItem expectationWithIndex = expectationsWithIndexes[index]; + string[] failures = getFailures(expectationWithIndex.Item, bestAssignment[index], expectationWithIndex.Index); + result.Add((expectationWithIndex, bestAssignment[index], failures)); + } + + return result; + } + + /// + /// Finds a near-optimal assignment using a greedy strategy. Suitable for large collections where the exact + /// permutation search would be prohibitively expensive. All distances are already cached from Phase 1, so + /// this is O(n² log n) rather than O(n! × n). + /// + private static IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByGreedyAssignment( + List remainingSubjects, IndexedItemCollection expectationsWithIndexes, + Func getFailures) + { + int subjectCount = remainingSubjects.Count; + int expectationCount = expectationsWithIndexes.Count; + int pairCount = expectationCount * subjectCount; + + var allPairs = new List<(int ExpectationIndex, int SubjectIndex, int Count)>(pairCount); + + for (int expectationIndex = 0; expectationIndex < expectationCount; expectationIndex++) + { + IndexedItem exp = expectationsWithIndexes[expectationIndex]; + + for (int subjectIndex = 0; subjectIndex < subjectCount; subjectIndex++) + { + int count = getFailures(exp.Item, remainingSubjects[subjectIndex], exp.Index).Length; + allPairs.Add((expectationIndex, subjectIndex, count)); } } - return bestSet is not null ? bestSet : Array.Empty<(IndexedItem, object, string[])>(); + // Sort by distance ascending. When distances are equal, use expectation index then subject index as + // tiebreakers so that assignments are deterministic and follow natural ordering. + allPairs.Sort(static (a, b) => + { + int relativeOrder = a.Count.CompareTo(b.Count); + if (relativeOrder != 0) + { + return relativeOrder; + } + + relativeOrder = a.ExpectationIndex.CompareTo(b.ExpectationIndex); + return relativeOrder != 0 ? relativeOrder : a.SubjectIndex.CompareTo(b.SubjectIndex); + }); + + var assignedExpectationIndexes = new bool[expectationCount]; + var assignedSubjectIndexes = new bool[subjectCount]; + int totalToAssign = Math.Min(expectationCount, subjectCount); + + var result = new List<(IndexedItem, object, string[])>(totalToAssign); + + foreach (var (expectationIndex, subjectIndex, _) in allPairs) + { + if (!assignedExpectationIndexes[expectationIndex] && !assignedSubjectIndexes[subjectIndex]) + { + string[] failures = getFailures(expectationsWithIndexes[expectationIndex].Item, remainingSubjects[subjectIndex], expectationsWithIndexes[expectationIndex].Index); + result.Add((expectationsWithIndexes[expectationIndex], remainingSubjects[subjectIndex], failures)); + assignedExpectationIndexes[expectationIndex] = true; + assignedSubjectIndexes[subjectIndex] = true; + + if (result.Count == totalToAssign) + { + break; + } + } + } + + return result; } private string[] TryToMatch(TExpectation expectation, object subject, int expectationIndex) @@ -201,28 +282,4 @@ private string[] TryToMatch(TExpectation expectation, object subject, int expect return failures; } - - /// - /// Provides a mechanism for comparing tuples that consist of a subject, an expectation, - /// and an expectation index. The comparison is based on object references and the expectation index. - /// - private sealed class ReferentialComparer : IEqualityComparer<(object Subject, object Expectation, int ExpectationIndex)> - { - public bool Equals((object Subject, object Expectation, int ExpectationIndex) x, - (object Subject, object Expectation, int ExpectationIndex) y) - { - return ReferenceEquals(x.Subject, y.Subject) - && ReferenceEquals(x.Expectation, y.Expectation) - && x.ExpectationIndex == y.ExpectationIndex; - } - - public int GetHashCode((object Subject, object Expectation, int ExpectationIndex) obj) - { - int hashCode = RuntimeHelpers.GetHashCode(obj.Subject); - hashCode = (hashCode * 397) + RuntimeHelpers.GetHashCode(obj.Expectation); - hashCode = (hashCode * 397) + obj.ExpectationIndex; - return hashCode; - } - } } - diff --git a/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs b/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs new file mode 100644 index 000000000..6b8241bab --- /dev/null +++ b/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace FluentAssertions.Equivalency.Steps; + +/// +/// Provides a mechanism for comparing tuples that consist of a subject, an expectation, +/// and an expectation index. The comparison is based on object references and the expectation index. +/// +[System.Diagnostics.StackTraceHidden] +internal sealed class ReferentialComparer : IEqualityComparer<(object Subject, object Expectation, int ExpectationIndex)> +{ + public bool Equals((object Subject, object Expectation, int ExpectationIndex) x, + (object Subject, object Expectation, int ExpectationIndex) y) + { + return ReferenceEquals(x.Subject, y.Subject) + && ReferenceEquals(x.Expectation, y.Expectation) + && x.ExpectationIndex == y.ExpectationIndex; + } + + public int GetHashCode((object Subject, object Expectation, int ExpectationIndex) obj) + { + int hashCode = RuntimeHelpers.GetHashCode(obj.Subject); + hashCode = (hashCode * 397) + RuntimeHelpers.GetHashCode(obj.Expectation); + hashCode = (hashCode * 397) + obj.ExpectationIndex; + return hashCode; + } +} diff --git a/Src/FluentAssertions/FluentAssertions.csproj b/Src/FluentAssertions/FluentAssertions.csproj index d7b4f66a6..0482bcb6a 100644 --- a/Src/FluentAssertions/FluentAssertions.csproj +++ b/Src/FluentAssertions/FluentAssertions.csproj @@ -47,7 +47,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Tests/Benchmarks/Program.cs b/Tests/Benchmarks/Program.cs index ff2fb9ab1..2405f2bef 100644 --- a/Tests/Benchmarks/Program.cs +++ b/Tests/Benchmarks/Program.cs @@ -24,6 +24,6 @@ public static void Main() var config = ManualConfig.CreateMinimumViable().AddExporter(exporter); - _ = BenchmarkRunner.Run(config); + _ = BenchmarkRunner.Run(config); } } From ad4ae6b26d5e2e1d5a1708cc0cf3e2633dd83e2a Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Wed, 8 Apr 2026 21:44:24 +0200 Subject: [PATCH 09/43] Pre-cache collection index strings to reduce ToString allocations AsCollectionItem(int) now returns a pre-computed string for indices 0-1023 instead of calling ToString() on every comparison. For large collections this avoids repeated short-lived string allocations in hot paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EquivalencyValidationContextExtensions.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Src/FluentAssertions/Equivalency/Steps/EquivalencyValidationContextExtensions.cs b/Src/FluentAssertions/Equivalency/Steps/EquivalencyValidationContextExtensions.cs index e3a85593d..febfd913c 100644 --- a/Src/FluentAssertions/Equivalency/Steps/EquivalencyValidationContextExtensions.cs +++ b/Src/FluentAssertions/Equivalency/Steps/EquivalencyValidationContextExtensions.cs @@ -5,8 +5,24 @@ namespace FluentAssertions.Equivalency.Steps; [System.Diagnostics.StackTraceHidden] internal static class EquivalencyValidationContextExtensions { + // Pre-cached string representations of common collection indices to avoid repeated allocations. + private static readonly string[] CachedIndexStrings = InitializeCachedIndexStrings(1024); + + private static string[] InitializeCachedIndexStrings(int count) + { + var result = new string[count]; + for (int i = 0; i < count; i++) + { + result[i] = i.ToString(CultureInfo.InvariantCulture); + } + + return result; + } + public static IEquivalencyValidationContext AsCollectionItem(this IEquivalencyValidationContext context, int index) => - context.AsCollectionItem(index.ToString(CultureInfo.InvariantCulture)); + context.AsCollectionItem(index < CachedIndexStrings.Length + ? CachedIndexStrings[index] + : index.ToString(CultureInfo.InvariantCulture)); } From 40550e9ba4decbbbb34d4cc79fc66fff49be7ed8 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Wed, 8 Apr 2026 21:47:29 +0200 Subject: [PATCH 10/43] Skip failure message formatting in dry-run comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add UseDryRun flag to AssertionScope. When set, AssertionChain.FailWith() increments a counter instead of formatting and storing the full message. LooselyOrderedEquivalencyStrategy uses a dry-run scope in TryToMatchCount to count failures cheaply, split caches (countCache for counts, fullFailuresCache for strings), and updates FindClosestMismatches to take separate getFailureCount and getFullFailures delegates — deferring string allocation to the winning assignment only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LooselyOrderedEquivalencyStrategy.cs | 85 ++++++++++++++----- .../Execution/AssertionChain.cs | 18 +++- .../Execution/AssertionScope.cs | 22 +++++ 3 files changed, 100 insertions(+), 25 deletions(-) diff --git a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs index 58a16f238..0487913a0 100644 --- a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs +++ b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs @@ -18,11 +18,16 @@ internal class LooselyOrderedEquivalencyStrategy( private readonly Tracer tracer = context.Tracer; - private Dictionary<(object Subject, object Expectation, int ExpectationIndex), string[]> failuresCache = new(); + // Populated during Phase 1 + 2 using skip-formatting dry-runs (no string allocation). + private Dictionary<(object Subject, object Expectation, int ExpectationIndex), int> countCache = new(); + + // Populated lazily during Phase 3 for the ~n selected pairs with full formatting. + private Dictionary<(object Subject, object Expectation, int ExpectationIndex), string[]> fullFailuresCache = new(); public void FindAndRemoveMatches(List subjects, List expectations) { - failuresCache = new(new ReferentialComparer()); + countCache = new(new ReferentialComparer()); + fullFailuresCache = new(new ReferentialComparer()); var expectationsWithIndexes = new IndexedItemCollection(expectations); @@ -66,15 +71,15 @@ private bool StrictlyMatchAgainst(List remainingSubjects, TExpectation e using var _ = tracer.WriteBlock(member => $"Comparing subject at {member.Subject}[{index}] with the expectation at {member.Expectation}[{expectationIndex}]"); - string[] failures = TryToMatch(expectation, subject, expectationIndex); - if (failures.Length == 0) + int failures = TryToMatchCount(expectation, subject, expectationIndex); + if (failures == 0) { tracer.WriteLine(_ => "It's a match"); remainingSubjects.RemoveAt(index); return true; } - tracer.WriteLine(_ => $"Contained {failures.Length} failures"); + tracer.WriteLine(_ => $"Contained {failures} failures"); } return false; @@ -94,7 +99,7 @@ private IndexedItemCollection SortExpectationsByMinDistance(List new { Expectation = e, - MinDistance = remainingSubjects.Min(a => TryToMatch(e.Item, a, e.Index).Length) + MinDistance = remainingSubjects.Min(a => TryToMatchCount(e.Item, a, e.Index)) }) .OrderBy(x => x.MinDistance) .Select(x => x.Expectation) @@ -113,7 +118,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, if (expectationsWithIndexes.Count > 0 && remainingSubjects.Count > 0) { IReadOnlyList<(IndexedItem, object, string[])> bestMatches = - FindClosestMismatches(remainingSubjects, expectationsWithIndexes, TryToMatch); + FindClosestMismatches(remainingSubjects, expectationsWithIndexes, TryToMatchCount, TryToMatch); foreach (var (expectation, subject, failures) in bestMatches) { @@ -134,23 +139,27 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, private static IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatches( List remainingSubjects, IndexedItemCollection expectationsWithIndexes, - Func getFailures) + Func getFailureCount, + Func getFullFailures) { // For small collections, use exact permutation search to find the globally optimal assignment. // factorial(8) = 40,320 which is well within reason. const int maxSizeForExactSearch = 8; return remainingSubjects.Count <= maxSizeForExactSearch ? - FindClosestMismatchesByPermutation(remainingSubjects, expectationsWithIndexes, getFailures) : - FindClosestMismatchesByGreedyAssignment(remainingSubjects, expectationsWithIndexes, getFailures); + FindClosestMismatchesByPermutation(remainingSubjects, expectationsWithIndexes, getFailureCount, getFullFailures) : + FindClosestMismatchesByGreedyAssignment(remainingSubjects, expectationsWithIndexes, getFailureCount, getFullFailures); } /// /// Finds the best assignment by exhaustively trying all permutations. Only suitable for small collections. + /// Uses failure counts for scoring (no string formatting) and only fetches full failure strings for the + /// winning assignment. /// private static IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByPermutation( List remainingSubjects, IndexedItemCollection expectationsWithIndexes, - Func getFailures) + Func getFailureCount, + Func getFullFailures) { var bestScore = int.MaxValue; IReadOnlyList bestAssignment = null; @@ -163,7 +172,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, for (int index = 0; index < expectationsWithIndexes.Count && index < assignment.Count; index++) { IndexedItem expectationWithIndex = expectationsWithIndexes[index]; - score += getFailures(expectationWithIndex.Item, assignment[index], expectationWithIndex.Index).Length; + score += getFailureCount(expectationWithIndex.Item, assignment[index], expectationWithIndex.Index); if (score >= bestScore) { @@ -184,13 +193,14 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, return Array.Empty<(IndexedItem, object, string[])>(); } + // Fetch full failure strings only for the winning assignment. int pairCount = Math.Min(expectationsWithIndexes.Count, bestAssignment.Count); var result = new List<(IndexedItem, object, string[])>(pairCount); for (int index = 0; index < pairCount; index++) { IndexedItem expectationWithIndex = expectationsWithIndexes[index]; - string[] failures = getFailures(expectationWithIndex.Item, bestAssignment[index], expectationWithIndex.Index); + string[] failures = getFullFailures(expectationWithIndex.Item, bestAssignment[index], expectationWithIndex.Index); result.Add((expectationWithIndex, bestAssignment[index], failures)); } @@ -204,12 +214,14 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, /// private static IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByGreedyAssignment( List remainingSubjects, IndexedItemCollection expectationsWithIndexes, - Func getFailures) + Func getFailureCount, + Func getFullFailures) { int subjectCount = remainingSubjects.Count; int expectationCount = expectationsWithIndexes.Count; int pairCount = expectationCount * subjectCount; + // Use failure counts (no string allocation) to build the pair list for sorting/assignment. var allPairs = new List<(int ExpectationIndex, int SubjectIndex, int Count)>(pairCount); for (int expectationIndex = 0; expectationIndex < expectationCount; expectationIndex++) @@ -218,7 +230,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, for (int subjectIndex = 0; subjectIndex < subjectCount; subjectIndex++) { - int count = getFailures(exp.Item, remainingSubjects[subjectIndex], exp.Index).Length; + int count = getFailureCount(exp.Item, remainingSubjects[subjectIndex], exp.Index); allPairs.Add((expectationIndex, subjectIndex, count)); } } @@ -243,11 +255,16 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, var result = new List<(IndexedItem, object, string[])>(totalToAssign); + // First checks candidate matches from best to worst, then picks the first unused expectation/subject pair it finds, + // computes detailed failures only for that chosen pair, and then repeats until all possible matches are assigned. foreach (var (expectationIndex, subjectIndex, _) in allPairs) { if (!assignedExpectationIndexes[expectationIndex] && !assignedSubjectIndexes[subjectIndex]) { - string[] failures = getFailures(expectationsWithIndexes[expectationIndex].Item, remainingSubjects[subjectIndex], expectationsWithIndexes[expectationIndex].Index); + // Fetch full failure strings only for the selected pairs (~n total). + string[] failures = getFullFailures(expectationsWithIndexes[expectationIndex].Item, + remainingSubjects[subjectIndex], expectationsWithIndexes[expectationIndex].Index); + result.Add((expectationsWithIndexes[expectationIndex], remainingSubjects[subjectIndex], failures)); assignedExpectationIndexes[expectationIndex] = true; assignedSubjectIndexes[subjectIndex] = true; @@ -262,23 +279,49 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, return result; } + /// + /// Performs a dry-run comparison using a scope that skips formatting. Returns the number of failures + /// without allocating any string messages. Used in Phase 1 and Phase 2 to avoid eager + /// FailureMessageFormatter + Regex.Replace costs for pairs that will ultimately be discarded. + /// + private int TryToMatchCount(TExpectation expectation, object subject, int expectationIndex) + { + var cacheKey = (subject, (object)expectation, expectationIndex); + + if (countCache.TryGetValue(cacheKey, out int cachedCount)) + { + return cachedCount; + } + + using var scope = new AssertionScope { UseDryRun = true }; + + IEquivalencyValidationContext itemContext = context.AsCollectionItem(expectationIndex); + + parent.AssertEquivalencyOf(new Comparands(subject, expectation, typeof(TExpectation)), itemContext); + + int count = scope.GetFailureCount(); + countCache[cacheKey] = count; + + return count; + } + private string[] TryToMatch(TExpectation expectation, object subject, int expectationIndex) { - // Create a cache key based on the subject and expectation instances var cacheKey = (subject, (object)expectation, expectationIndex); - if (failuresCache.TryGetValue(cacheKey, out string[] cachedResult)) + if (fullFailuresCache.TryGetValue(cacheKey, out string[] cachedResult)) { return cachedResult; } using var scope = new AssertionScope(); - parent.AssertEquivalencyOf(new Comparands(subject, expectation, typeof(TExpectation)), - context.AsCollectionItem(expectationIndex)); + IEquivalencyValidationContext itemContext = context.AsCollectionItem(expectationIndex); + + parent.AssertEquivalencyOf(new Comparands(subject, expectation, typeof(TExpectation)), itemContext); string[] failures = scope.Discard(); - failuresCache[cacheKey] = failures; + fullFailuresCache[cacheKey] = failures; return failures; } diff --git a/Src/FluentAssertions/Execution/AssertionChain.cs b/Src/FluentAssertions/Execution/AssertionChain.cs index 05f1f8fe7..7c68a1069 100644 --- a/Src/FluentAssertions/Execution/AssertionChain.cs +++ b/Src/FluentAssertions/Execution/AssertionChain.cs @@ -252,14 +252,24 @@ private Continuation FailWith(Func getFailureReason) if (succeeded != true) { - string failure = getFailureReason(); + AssertionScope scope = getCurrentScope(); - if (expectation is not null) + if (scope.UseDryRun) { - failure = expectation() + failure; + // Dry-run mode: skip all string formatting and only count the failure. + scope.RegisterFailure(); } + else + { + string failure = getFailureReason(); + + if (expectation is not null) + { + failure = expectation() + failure; + } - getCurrentScope().AddPreFormattedFailure(failure.Capitalize().RemoveTrailingWhitespaceFromLines()); + scope.AddPreFormattedFailure(failure.Capitalize().RemoveTrailingWhitespaceFromLines()); + } } } diff --git a/Src/FluentAssertions/Execution/AssertionScope.cs b/Src/FluentAssertions/Execution/AssertionScope.cs index 2daa2ab8e..bee8b4661 100644 --- a/Src/FluentAssertions/Execution/AssertionScope.cs +++ b/Src/FluentAssertions/Execution/AssertionScope.cs @@ -26,6 +26,9 @@ public sealed class AssertionScope : IDisposable private AssertionScope parent; + // Tracks failure count when SkipFormattingForFailures is enabled (no string storage needed). + private int failureCount; + /// /// Starts an unnamed scope within which multiple assertions can be executed /// and which will not throw until the scope is disposed. @@ -133,6 +136,25 @@ public static AssertionScope Current /// public FormattingOptions FormattingOptions { get; } = AssertionConfiguration.Current.Formatting.Clone(); + /// + /// When set to true, will skip building failure message + /// strings and only track the number of failures. Use this for dry-run comparisons (e.g. collection + /// matching) where the actual formatted messages are not needed and would be discarded anyway. + /// + internal bool UseDryRun { get; set; } + + /// + /// Returns the number of failures recorded while is enabled. + /// Only counts direct failures tracked via . + /// + internal int GetFailureCount() => failureCount; + + /// + /// Increments the failure counter without storing or formatting a failure message. + /// Only called from when is active. + /// + internal void RegisterFailure() => failureCount++; + /// /// Adds a pre-formatted failure message to the current scope. /// From 02ad7f16e7e869ee2cf006bcf200e215d8a45d8e Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Wed, 8 Apr 2026 21:48:59 +0200 Subject: [PATCH 11/43] Clone CyclicReferenceDetector for dry-run comparisons to prevent stack overflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dry-run comparisons in TryToMatchCount and TryToMatch share the parent context's CyclicReferenceDetector by default. When the same object appears in multiple positions in an unordered collection, the detector could flag subsequent dry-runs as cyclic references and skip them — producing wrong failure counts and potential stack overflows. Fix by cloning the detector before each dry-run invocation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Equivalency/EquivalencyValidationContext.cs | 2 +- .../Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Src/FluentAssertions/Equivalency/EquivalencyValidationContext.cs b/Src/FluentAssertions/Equivalency/EquivalencyValidationContext.cs index f35899b18..c00e6ae22 100644 --- a/Src/FluentAssertions/Equivalency/EquivalencyValidationContext.cs +++ b/Src/FluentAssertions/Equivalency/EquivalencyValidationContext.cs @@ -32,7 +32,7 @@ public EquivalencyValidationContext(INode root, IEquivalencyOptions options) public IEquivalencyOptions Options { get; } - private CyclicReferenceDetector CyclicReferenceDetector { get; set; } + internal CyclicReferenceDetector CyclicReferenceDetector { get; set; } public IEquivalencyValidationContext AsNestedMember(IMember expectationMember) { diff --git a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs index 0487913a0..b92f89db6 100644 --- a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs +++ b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using FluentAssertions.Common; +using FluentAssertions.Equivalency.Execution; using FluentAssertions.Equivalency.Tracing; using FluentAssertions.Execution; @@ -295,7 +296,8 @@ private int TryToMatchCount(TExpectation expectation, object subject, int expect using var scope = new AssertionScope { UseDryRun = true }; - IEquivalencyValidationContext itemContext = context.AsCollectionItem(expectationIndex); + var itemContext = (EquivalencyValidationContext)context.AsCollectionItem(expectationIndex); + itemContext.CyclicReferenceDetector = (CyclicReferenceDetector)((EquivalencyValidationContext)context).CyclicReferenceDetector.Clone(); parent.AssertEquivalencyOf(new Comparands(subject, expectation, typeof(TExpectation)), itemContext); @@ -316,7 +318,8 @@ private string[] TryToMatch(TExpectation expectation, object subject, int expect using var scope = new AssertionScope(); - IEquivalencyValidationContext itemContext = context.AsCollectionItem(expectationIndex); + var itemContext = (EquivalencyValidationContext)context.AsCollectionItem(expectationIndex); + itemContext.CyclicReferenceDetector = (CyclicReferenceDetector)((EquivalencyValidationContext)context).CyclicReferenceDetector.Clone(); parent.AssertEquivalencyOf(new Comparands(subject, expectation, typeof(TExpectation)), itemContext); From 7ae21ac2b7c63391ee203d4fbd6bba2f221d3c56 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Wed, 8 Apr 2026 21:49:21 +0200 Subject: [PATCH 12/43] Include nested scope failures in dry-run failure count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetFailureCount() was only counting direct RegisterFailure() calls. Failures from nested assertion scopes (e.g. via AssertionRuleEquivalencyStep) are stored in assertionStrategy.FailureMessages rather than the local counter, so they were invisible to TryToMatchCount — causing mismatches to look like matches (count=0) and corrupting the optimal assignment. Also call scope.Discard() after GetFailureCount() so that the dry-run scope does not propagate its failure messages to the parent scope on disposal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .packageguard/cache.bin | Bin 131545 -> 114563 bytes .../LooselyOrderedEquivalencyStrategy.cs | 1 + .../Execution/AssertionScope.cs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.packageguard/cache.bin b/.packageguard/cache.bin index 8907a098efd845e3e19c2fa757b0f8c6ace74115..509299b601da1d928507e2b0ff77267600e05f30 100644 GIT binary patch delta 1427 zcmZ`&e@v8R9OrW!zvPZP1gCi5@heXtUhaj1fnA2tB3Xg0#4Rev7>1#S9ILkcwJ~#R zX~Ls?0~2YsHoC12-;EbRevd*Sq0Lbu2}K*VS`r`%f!A{{QT(XC-sk=NdYDz&D-rVN?#pfk*w5Yk{mP|#Lm=8(y7aK zm1|JmqLZ>e_JfoqzrMx=F#R1JRr?@Fy4^9NR*>(n`7>>okJQ&)3Ku|*&G4q`Ja|bt zJuN(DP)&hru!AZAE9ekEz{Q|Q>c z#z^-5z_fI6WH^sx%UDFwDPm{Fhb`1PoUOLyhVk#kWMvU z;{@&K!aGZTn}-lglV2il3HDwelIUVL3gOPm0-t8%X(3F%$4cgTgvzbM2mpm#N6CM% z#Mxf?l^)FD|5!TkJK`zmI`m{6K!%?vqzGc%{(X7*2W|PQ=UYhREAsJ@Z{Eaa$a}uU zGhQ?}gH(1V_Aq_W4?Eo$!Q0EA0^*B!oIV_bhSnd2NF&_{psZ17S}TD$cjr;>?1DrTU&AAp{1hyMPG#%nqX} z$xJSyd5THuh1feU8(4&2M(B!NHymXNJpz+qMSNx za8DHe_8RLEONBCw6+kc>k62I5GA}Q_3015r(m;+Ye2=Fa5d9DTYr_hD8!U9Fi`O33 zhz+o?=L0HP=rIrat`R}*$%2`t8ev5;eG`K7)Z2vT6!7>YcVu3LehDLxrWKJK5L=PX zKkaSkBx@VYe0-n{FDyVmKxBPq6KJ3vetd}OGJ3NE>DpPR)9K;n=9VDD^WI#FhaJ%H zYAuRp{3PFry%T|kMd6FP4(=sj=tZKqQnHl&K=u}Pn4doP9s7gv=3m`F4`cyC{4N zqU`?=T2S^3wzyEH@-;E@VNYtA;csw}9iBy;;tBhcAIT!hr}U?jbN|MfX=o0^=9K@A><_^PO|gVM}-5`7Kgd*bd*sL$F<*Ch=qJ;gs+tON+RY8eVT(K`r}@)f>QW zo;_az0Zg9uL$>JQtMoUBim5s)SosO5z3`E5=dh=9me^aC%mGo_)B)05aydOSZCuC{ z>ZA6?+!{a3$b&?C`SRu|5ahuMwkE~Ex+*i7a%wCyHcRdHnqNK?d9%B_J7m(NNyU7X zk=F}`D)!OtSay;n#P;>nD^#48Q!0!GJ#V)0;B?_Vzf4y2mpE)`f$8?F=R+O{_9H1> zBrTh@2PO^#09BhHpUFFItn*!&J><}CKi{a&wMd!bSdhKizAkh`4!gW_eZYuRPAimO zNU^nwkXm_W2l&K-pKxQ&Lqbl{Q7Gyh1}WX{f-Uw_0qLDnZT_=9S7987izgYdt-kdE+3e z1#DB{G}it15KW~;F!O?046N^2_ z@&m4s;8?sOgE$5`@sn@SYap?x(z#SFl{E!rveh{e(bVqCVDAF4tr}+FQ(GW{wSFC4 zC}Ket`%dD zm!=g2djpvYlbHWe>=kJ&D{h}3;9;7J6%1GKAl3I}f|v**a@-O#C6m>rGcGllD=l@l zBBM^IvRF;MKx)ObnovMy`3$7=G6g_RNo95A2d6XFbL`r|DsMosb1e}PCt*>?a<7EP z_e#SCNZx$1!W%>Mr({mzU~|-`O(zE29#&_>Zs&!VrvS+t)op_^&S6tXL#W9%BlsS#`dT}v5IXk47uBzxUpvPTZBaBNJxx!4{}d4d8|mB zASC7uhlEyh2Q_YjZOTSb<2=q0t!C_1E{Q;%5T81Y()Y*|?eOs)-oLwA=K}r*pL31U zPVzBlh>z>;D5HJ<${4RoKGqpN{Hjf83?FabzTEF=t?U1E_q1wz^zN=^v!i>wV(sqX z@D*vOhxhBMJqU^WV%7Fg;WD&$ux6{0C)7d8DAsHb6)r>jU9NDRy!-A{xFH_iw-s(6 z692yyZn5uY4WH8l*(XTb}X8f%LVsZOJz$H^BS5~AGs@;YKg@#LayO$%3 zJsr%`H3=TihDf|Fqo1pxFx6p-gXgnwax=*NRh$x4&5#Q!)-L-welLcuKcW$4J3@X0 zABLj>A;B*#70VD7($oH|V!s3nc0&dpZh-{Q;Hffr45$ANV*E9n$~Ao9_mJZFb2Y>R zO83yOl6@7a#^rk;0#C)jE(~oW9q})LwrAv-mj;i7;j+D;#_j@0#gls>#>H2&4`%wO zb1H1w2bp4nEuQiM8eWD1=SzdX8ozuQBHTwfBvme8AR#@kHjcR@RAH7Il7Hw&;a0hXhve8#s_rUGaRmC{X>aEo4?cZd{9bvia3fJY@_tDon<>o5m* zAB0)Xegq?B&c6=Al%ZtvU0q`SCmn+DK?D~MLB6X?%-t33AR9#BRFv5Pvmg!2JD`vr z2Rq;$P~u;s39hb_AeB&Zsm0zlF~1|R?l45r__o7PfUkGJtFGSX{?OYyFGO!KnJi`} zTh>-sh^;5!OtS+L#n~9*6dH~e2ZUkvR>*gb;?_4oK|+tb3CrAiN#AUjs(B$Mp(dqP z)o;sV#~_Kus*XXa8^^oHK~254kHd<--fN`ye6Uk*RCxbC8c;Maat*X1&3egy0beNf A@Bjb+ diff --git a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs index b92f89db6..c672d212c 100644 --- a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs +++ b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs @@ -302,6 +302,7 @@ private int TryToMatchCount(TExpectation expectation, object subject, int expect parent.AssertEquivalencyOf(new Comparands(subject, expectation, typeof(TExpectation)), itemContext); int count = scope.GetFailureCount(); + scope.Discard(); countCache[cacheKey] = count; return count; diff --git a/Src/FluentAssertions/Execution/AssertionScope.cs b/Src/FluentAssertions/Execution/AssertionScope.cs index bee8b4661..2060d9464 100644 --- a/Src/FluentAssertions/Execution/AssertionScope.cs +++ b/Src/FluentAssertions/Execution/AssertionScope.cs @@ -147,7 +147,7 @@ public static AssertionScope Current /// Returns the number of failures recorded while is enabled. /// Only counts direct failures tracked via . /// - internal int GetFailureCount() => failureCount; + internal int GetFailureCount() => failureCount + assertionStrategy.FailureMessages.Count(); /// /// Increments the failure counter without storing or formatting a failure message. From f429c2ee065eb04e8ef7d5b5aab3006439d38164 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 12 Apr 2026 14:12:36 +0200 Subject: [PATCH 13/43] Make closest-match helpers instance methods The strategy already owns TryToMatchCount and TryToMatch, so keeping the closest-match helper chain static forced delegate plumbing without adding value. Make those helpers instance methods so they can use the existing instance members directly while preserving the matching logic, scoring, and caches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LooselyOrderedEquivalencyStrategy.cs | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs index c672d212c..5bd348c3e 100644 --- a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs +++ b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs @@ -119,7 +119,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, if (expectationsWithIndexes.Count > 0 && remainingSubjects.Count > 0) { IReadOnlyList<(IndexedItem, object, string[])> bestMatches = - FindClosestMismatches(remainingSubjects, expectationsWithIndexes, TryToMatchCount, TryToMatch); + FindClosestMismatches(remainingSubjects, expectationsWithIndexes); foreach (var (expectation, subject, failures) in bestMatches) { @@ -138,18 +138,16 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, } } - private static IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatches( - List remainingSubjects, IndexedItemCollection expectationsWithIndexes, - Func getFailureCount, - Func getFullFailures) + private IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatches( + List remainingSubjects, IndexedItemCollection expectationsWithIndexes) { // For small collections, use exact permutation search to find the globally optimal assignment. // factorial(8) = 40,320 which is well within reason. const int maxSizeForExactSearch = 8; return remainingSubjects.Count <= maxSizeForExactSearch ? - FindClosestMismatchesByPermutation(remainingSubjects, expectationsWithIndexes, getFailureCount, getFullFailures) : - FindClosestMismatchesByGreedyAssignment(remainingSubjects, expectationsWithIndexes, getFailureCount, getFullFailures); + FindClosestMismatchesByPermutation(remainingSubjects, expectationsWithIndexes) : + FindClosestMismatchesByGreedyAssignment(remainingSubjects, expectationsWithIndexes); } /// @@ -157,10 +155,8 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, /// Uses failure counts for scoring (no string formatting) and only fetches full failure strings for the /// winning assignment. /// - private static IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByPermutation( - List remainingSubjects, IndexedItemCollection expectationsWithIndexes, - Func getFailureCount, - Func getFullFailures) + private IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByPermutation( + List remainingSubjects, IndexedItemCollection expectationsWithIndexes) { var bestScore = int.MaxValue; IReadOnlyList bestAssignment = null; @@ -173,7 +169,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, for (int index = 0; index < expectationsWithIndexes.Count && index < assignment.Count; index++) { IndexedItem expectationWithIndex = expectationsWithIndexes[index]; - score += getFailureCount(expectationWithIndex.Item, assignment[index], expectationWithIndex.Index); + score += TryToMatchCount(expectationWithIndex.Item, assignment[index], expectationWithIndex.Index); if (score >= bestScore) { @@ -201,7 +197,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, for (int index = 0; index < pairCount; index++) { IndexedItem expectationWithIndex = expectationsWithIndexes[index]; - string[] failures = getFullFailures(expectationWithIndex.Item, bestAssignment[index], expectationWithIndex.Index); + string[] failures = TryToMatch(expectationWithIndex.Item, bestAssignment[index], expectationWithIndex.Index); result.Add((expectationWithIndex, bestAssignment[index], failures)); } @@ -213,10 +209,8 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, /// permutation search would be prohibitively expensive. All distances are already cached from Phase 1, so /// this is O(n² log n) rather than O(n! × n). /// - private static IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByGreedyAssignment( - List remainingSubjects, IndexedItemCollection expectationsWithIndexes, - Func getFailureCount, - Func getFullFailures) + private IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByGreedyAssignment( + List remainingSubjects, IndexedItemCollection expectationsWithIndexes) { int subjectCount = remainingSubjects.Count; int expectationCount = expectationsWithIndexes.Count; @@ -231,7 +225,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, for (int subjectIndex = 0; subjectIndex < subjectCount; subjectIndex++) { - int count = getFailureCount(exp.Item, remainingSubjects[subjectIndex], exp.Index); + int count = TryToMatchCount(exp.Item, remainingSubjects[subjectIndex], exp.Index); allPairs.Add((expectationIndex, subjectIndex, count)); } } @@ -263,7 +257,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, if (!assignedExpectationIndexes[expectationIndex] && !assignedSubjectIndexes[subjectIndex]) { // Fetch full failure strings only for the selected pairs (~n total). - string[] failures = getFullFailures(expectationsWithIndexes[expectationIndex].Item, + string[] failures = TryToMatch(expectationsWithIndexes[expectationIndex].Item, remainingSubjects[subjectIndex], expectationsWithIndexes[expectationIndex].Index); result.Add((expectationsWithIndexes[expectationIndex], remainingSubjects[subjectIndex], failures)); From 431e5a8c7e7560536af8c6fd9fdbe5caa0a711c0 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:36:00 +0200 Subject: [PATCH 14/43] Meziantou.Analyzer 2.0.260 -> 3.0.50 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index eb3303cc8..69be3ee97 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -34,7 +34,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 48cca1fae07835c610f62bc66ded7371a1f54b1f Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:37:16 +0200 Subject: [PATCH 15/43] TUnit 1.5.60 -> 1.37.0 --- Tests/TestFrameworks/TUnit.Specs/TUnit.Specs.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/TestFrameworks/TUnit.Specs/TUnit.Specs.csproj b/Tests/TestFrameworks/TUnit.Specs/TUnit.Specs.csproj index 3dd7c50b3..ae89b6c1a 100644 --- a/Tests/TestFrameworks/TUnit.Specs/TUnit.Specs.csproj +++ b/Tests/TestFrameworks/TUnit.Specs/TUnit.Specs.csproj @@ -11,7 +11,7 @@ - + From 0c21d1e0a21227015f823bad00795b5718c1b053 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:38:13 +0200 Subject: [PATCH 16/43] MSTest 4.0.2 -> 4.2.1 --- Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj b/Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj index 028bc9372..bc4de568b 100644 --- a/Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj +++ b/Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj @@ -12,7 +12,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + From 1dd2dc9a366c92a529a8c68b4630b984e2d77cf4 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:39:14 +0200 Subject: [PATCH 17/43] xunit.v3 3.2.1 -> 3.2.2 --- Tests/TestFrameworks/XUnit3.Specs/XUnit3.Specs.csproj | 2 +- Tests/TestFrameworks/XUnit3Core.Specs/XUnit3Core.Specs.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/TestFrameworks/XUnit3.Specs/XUnit3.Specs.csproj b/Tests/TestFrameworks/XUnit3.Specs/XUnit3.Specs.csproj index 88097f9f5..c7c2d290c 100644 --- a/Tests/TestFrameworks/XUnit3.Specs/XUnit3.Specs.csproj +++ b/Tests/TestFrameworks/XUnit3.Specs/XUnit3.Specs.csproj @@ -9,7 +9,7 @@ - + diff --git a/Tests/TestFrameworks/XUnit3Core.Specs/XUnit3Core.Specs.csproj b/Tests/TestFrameworks/XUnit3Core.Specs/XUnit3Core.Specs.csproj index cf8470a62..8def8827c 100644 --- a/Tests/TestFrameworks/XUnit3Core.Specs/XUnit3Core.Specs.csproj +++ b/Tests/TestFrameworks/XUnit3Core.Specs/XUnit3Core.Specs.csproj @@ -9,7 +9,7 @@ - + From 39143b18fa91eba6f17194c5bcfc28d0eb7d3bbc Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:42:12 +0200 Subject: [PATCH 18/43] SharpCompress 0.42.1 -> 0.47.4 --- Build/CompressionExtensions.cs | 2 +- Build/_build.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Build/CompressionExtensions.cs b/Build/CompressionExtensions.cs index 334960480..89753805b 100644 --- a/Build/CompressionExtensions.cs +++ b/Build/CompressionExtensions.cs @@ -9,7 +9,7 @@ public static void UnTarXzTo(this AbsolutePath archive, AbsolutePath directory) { using Stream stream = File.OpenRead(archive); - using var reader = ReaderFactory.Open(stream); + using var reader = ReaderFactory.OpenReader(stream); while (reader.MoveToNextEntry()) { diff --git a/Build/_build.csproj b/Build/_build.csproj index a1e6784b9..8ddb6c753 100644 --- a/Build/_build.csproj +++ b/Build/_build.csproj @@ -17,6 +17,6 @@ - + From 48130c3be73bfafe1bea1a3b926af89b8a771d3b Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:43:29 +0200 Subject: [PATCH 19/43] ReportGenerator 5.5.1 -> 5.5.5 --- Build/_build.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Build/_build.csproj b/Build/_build.csproj index 8ddb6c753..1e1d59c57 100644 --- a/Build/_build.csproj +++ b/Build/_build.csproj @@ -11,7 +11,7 @@ - + From f6f0f3ee3c382f6571ce81ead371811f647c983e Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:46:16 +0200 Subject: [PATCH 20/43] Limit Xunit.StaFact to xunitV2 compatible versions --- .../FluentAssertions.Equivalency.Specs.csproj | 2 +- Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj b/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj index 560da3337..193a4f01a 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj +++ b/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj @@ -32,7 +32,7 @@ all runtime; build; native; contentfiles; analyzers - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj b/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj index 11a0bcabd..42b87d501 100644 --- a/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj +++ b/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj @@ -31,7 +31,7 @@ all runtime; build; native; contentfiles; analyzers - + runtime; build; native; contentfiles; analyzers; buildtransitive From e9b2c1afb3d8c8e0f74cfc4c60d579f07798fc54 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:52:52 +0200 Subject: [PATCH 21/43] Upgrade Approval.Tests to xunit.v3 Verify.Xunit is deprecated, so moving to Verify.XunitV3 --- Tests/Approval.Tests/Approval.Tests.csproj | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/Approval.Tests/Approval.Tests.csproj b/Tests/Approval.Tests/Approval.Tests.csproj index 59464305e..ba378957c 100644 --- a/Tests/Approval.Tests/Approval.Tests.csproj +++ b/Tests/Approval.Tests/Approval.Tests.csproj @@ -2,18 +2,19 @@ net10.0 + Exe - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + From 93af1bd3207c3d3dc68352ef1c6fd007333ba324 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:53:32 +0200 Subject: [PATCH 22/43] Reflectify 1.9.0 -> 1.9.1 --- Src/FluentAssertions/FluentAssertions.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Src/FluentAssertions/FluentAssertions.csproj b/Src/FluentAssertions/FluentAssertions.csproj index 0482bcb6a..528baf976 100644 --- a/Src/FluentAssertions/FluentAssertions.csproj +++ b/Src/FluentAssertions/FluentAssertions.csproj @@ -47,7 +47,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From b338e1fc64cbabe8074ec0f2ac2c7b253719dad3 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:57:37 +0200 Subject: [PATCH 23/43] NUnit 4.4.0 -> 4.5.1 --- Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj b/Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj index 7ddcd3bea..f606846da 100644 --- a/Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj +++ b/Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj @@ -12,7 +12,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 40633d1c43b60b486871da97f1d0b0d573bc7d4c Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 11:59:28 +0200 Subject: [PATCH 24/43] JetBrains.Annotations 2024.3.0 -> 2025.2.4 --- Src/FluentAssertions/FluentAssertions.csproj | 2 +- .../FluentAssertions.Equivalency.Specs.csproj | 2 +- Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Src/FluentAssertions/FluentAssertions.csproj b/Src/FluentAssertions/FluentAssertions.csproj index 528baf976..327d5ffbb 100644 --- a/Src/FluentAssertions/FluentAssertions.csproj +++ b/Src/FluentAssertions/FluentAssertions.csproj @@ -59,7 +59,7 @@ - + diff --git a/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj b/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj index 193a4f01a..5ed4eef87 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj +++ b/Tests/FluentAssertions.Equivalency.Specs/FluentAssertions.Equivalency.Specs.csproj @@ -25,7 +25,7 @@ - + diff --git a/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj b/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj index 42b87d501..eb96bd99b 100644 --- a/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj +++ b/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj @@ -25,7 +25,7 @@ - + all From 3c248f6958cd4db7ae72f7641cdcb359a02a7137 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 12:05:07 +0200 Subject: [PATCH 25/43] xunit.runner.visualstudio 3.0.0 -> 3.0.2 --- Tests/FSharp.Specs/FSharp.Specs.fsproj | 2 +- Tests/VB.Specs/VB.Specs.vbproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/FSharp.Specs/FSharp.Specs.fsproj b/Tests/FSharp.Specs/FSharp.Specs.fsproj index 97234f438..c209ea6ed 100644 --- a/Tests/FSharp.Specs/FSharp.Specs.fsproj +++ b/Tests/FSharp.Specs/FSharp.Specs.fsproj @@ -14,7 +14,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Tests/VB.Specs/VB.Specs.vbproj b/Tests/VB.Specs/VB.Specs.vbproj index cfc7e3edc..80e541635 100644 --- a/Tests/VB.Specs/VB.Specs.vbproj +++ b/Tests/VB.Specs/VB.Specs.vbproj @@ -9,7 +9,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 1cb7ce9e23932a859bfcbdcb0f618eeb242f7464 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 12:26:19 +0200 Subject: [PATCH 26/43] Bump vulnerable dependencies of NUKE https://github.com/nuke-build/nuke/pull/1592 --- Build/_build.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Build/_build.csproj b/Build/_build.csproj index 1e1d59c57..46dbdb457 100644 --- a/Build/_build.csproj +++ b/Build/_build.csproj @@ -15,8 +15,10 @@ + + From 82aa225dfc619e1cb7a5b8ac7b1d1e51867eb768 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 13:42:06 +0200 Subject: [PATCH 27/43] Fixup Qodana issues * Don't use mutable member in GetHashCode * Remove unused using --- .../Equivalency/Selection/SelectMemberByPathSelectionRule.cs | 1 - .../SelectionRulesSpecs.Excluding.cs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs index 2f0fae01e..618dcd70c 100644 --- a/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; diff --git a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs index 21a676ba3..c84eca615 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs @@ -1436,9 +1436,9 @@ public class ClassWithCollectionOfValueSemantics public class ClassWithValueSemanticsAndNestedObject { - public string Key { get; set; } + public string Key { get; init; } - public NestedObjectWithProperty Child { get; set; } + public NestedObjectWithProperty Child { get; init; } protected bool Equals(ClassWithValueSemanticsAndNestedObject other) => Key == other.Key; From aeb92c2ae0cf59293d1243ea9f42e7338ea10a96 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Sun, 19 Apr 2026 14:19:15 +0200 Subject: [PATCH 28/43] Fix typo in release notes --- docs/_pages/releases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index 23c61839c..92b2014dd 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -57,7 +57,7 @@ sidebar: ### What's new -* Add `Value.ThatMatches` and `Value.ThatSatifies` to build inline assertions when using `BeEquivalentTo` - [#3076](https://github.com/fluentassertions/fluentassertions/pull/3076) +* Add `Value.ThatMatches` and `Value.ThatSatisfies` to build inline assertions when using `BeEquivalentTo` - [#3076](https://github.com/fluentassertions/fluentassertions/pull/3076) ## 8.5.0 From 3ca68beb0df96ff43e103f50068fe4af4a7e04ad Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Mon, 20 Apr 2026 09:36:59 +0200 Subject: [PATCH 29/43] Fix Qodana argument separator ``` Comma-separated args format is deprecated and will be removed in a future version. Please switch to space-separated format: Current: "--baseline,qodana.sarif.json,--ide,QDNET" Suggested: "--baseline qodana.sarif.json --ide QDNET" ``` --- .github/workflows/code_quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 02898b768..b975a04ef 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -21,7 +21,7 @@ jobs: uses: JetBrains/qodana-action@v2025.3 with: upload-result: ${{ github.ref_name == 'main' || github.ref_name == 'develop' }} - args: --baseline,qodana.sarif.json,--ide,QDNET + args: --baseline qodana.sarif.json --ide QDNET pr-mode: ${{ github.event_name == 'pull_request_target' }} env: QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} From 2d6d417945fc0535ff5da7d82f083ddb5159070e Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Mon, 20 Apr 2026 13:08:41 +0200 Subject: [PATCH 30/43] Use new Qodana linter option Due to this warning > Flag --ide has been deprecated, use --linter with corresponding linter type and --within-docker=false instead Changes done in accordance to: https://www.jetbrains.com/help/qodana/dotnet.html#Basic+use+case --- .github/workflows/code_quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index b975a04ef..e2ddda03f 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -21,7 +21,7 @@ jobs: uses: JetBrains/qodana-action@v2025.3 with: upload-result: ${{ github.ref_name == 'main' || github.ref_name == 'develop' }} - args: --baseline qodana.sarif.json --ide QDNET + args: --baseline qodana.sarif.json --linter qodana-dotnet --within-docker false pr-mode: ${{ github.event_name == 'pull_request_target' }} env: QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} From 79050a92a0c18e8938fbf303f31cfd4a9a7640f1 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Mon, 20 Apr 2026 14:55:00 +0200 Subject: [PATCH 31/43] Fix typos in docs --- docs/_pages/releases.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index 92b2014dd..a916f544f 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -51,7 +51,6 @@ sidebar: ### What's new * Added support for `System.Text.Json.JsonNode` and `JsonArray` through new assertions as well as the `BeEquivalentTo` API - [#3094](https://github.com/fluentassertions/fluentassertions/pull/3094) -* Added `WithoutMessage` to allow asserting an exception message does not contain a wildcard pattern - [#3100](https://github.com/fluentassertions/fluentassertions/pull/3100) ## 8.6.0 @@ -238,12 +237,12 @@ Version 7 will remain fully open-source indefinitely and receive bugfixes and ot ### Fixes -* The expectation node identified as a cyclic reference is still compared to the subject node using simple equality - [2819](https://github.com/fluentassertions/fluentassertions/pull/2819) +* The expectation node identified as a cyclic reference is still compared to the subject node using simple equality - [#2819](https://github.com/fluentassertions/fluentassertions/pull/2819) ## 6.12.2 ### Fixes -* Better handling of normal vs explicitly implemented vs default interface properties - [2794](https://github.com/fluentassertions/fluentassertions/pull/2794) +* Better handling of normal vs explicitly implemented vs default interface properties - [#2794](https://github.com/fluentassertions/fluentassertions/pull/2794) ## 6.12.1 @@ -422,7 +421,7 @@ Version 7 will remain fully open-source indefinitely and receive bugfixes and ot ### Fixes * Prevent multiple enumeration of `IEnumerable`s in parameter-less `ContainSingle()` - [#1753](https://github.com/fluentassertions/fluentassertions/pull/1753) * Changed `HaveCount()` assertion message order to state expected and actual collection count before dumping its content` - [#1760](https://github.com/fluentassertions/fluentassertions/pull/1760) -* `CompleteWithinAsync` did not take initial sync computation into account when measuring execution time - [1762](https://github.com/fluentassertions/fluentassertions/pull/1762). +* `CompleteWithinAsync` did not take initial sync computation into account when measuring execution time - [#1762](https://github.com/fluentassertions/fluentassertions/pull/1762). ## 6.2.0 From f1370c1662fc4c2e4c3e36ec24d34d919fb99a16 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:07:37 +0300 Subject: [PATCH 32/43] Fix flaky BeLessThanOrEqualTo execution time test (#3200) --- .../Specialized/ExecutionTimeAssertionsSpecs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/FluentAssertions.Specs/Specialized/ExecutionTimeAssertionsSpecs.cs b/Tests/FluentAssertions.Specs/Specialized/ExecutionTimeAssertionsSpecs.cs index d0f632baa..7277d16dc 100644 --- a/Tests/FluentAssertions.Specs/Specialized/ExecutionTimeAssertionsSpecs.cs +++ b/Tests/FluentAssertions.Specs/Specialized/ExecutionTimeAssertionsSpecs.cs @@ -63,7 +63,7 @@ public void When_the_execution_time_of_an_action_is_less_than_or_equal_to_a_limi Action someAction = () => Thread.Sleep(100); // Act - Action act = () => someAction.ExecutionTime().Should().BeLessThanOrEqualTo(1.Seconds()); + Action act = () => someAction.ExecutionTime().Should().BeLessThanOrEqualTo(2.Seconds()); // Assert act.Should().NotThrow(); From 84b7cb606674e8216d0f30f4e22f94a251689a9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:30:46 +0200 Subject: [PATCH 33/43] Bump JetBrains/qodana-action from 2025.3 to 2026.1 (#3201) Bumps [JetBrains/qodana-action](https://github.com/jetbrains/qodana-action) from 2025.3 to 2026.1. - [Commits](https://github.com/jetbrains/qodana-action/compare/v2025.3...v2026.1) --- updated-dependencies: - dependency-name: JetBrains/qodana-action dependency-version: '2026.1' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/code_quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index e2ddda03f..94699ce1c 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -18,7 +18,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: 'Qodana Scan' - uses: JetBrains/qodana-action@v2025.3 + uses: JetBrains/qodana-action@v2026.1 with: upload-result: ${{ github.ref_name == 'main' || github.ref_name == 'develop' }} args: --baseline qodana.sarif.json --linter qodana-dotnet --within-docker false From f81cb7c3d196ec9a30e788195bf26a2e61bc1500 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Tue, 5 May 2026 16:30:10 +0200 Subject: [PATCH 34/43] Use long for hashCode in ReferentialComparer to avoid overflow (#3204) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs b/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs index 6b8241bab..1d69a05e5 100644 --- a/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs +++ b/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs @@ -20,9 +20,9 @@ public bool Equals((object Subject, object Expectation, int ExpectationIndex) x, public int GetHashCode((object Subject, object Expectation, int ExpectationIndex) obj) { - int hashCode = RuntimeHelpers.GetHashCode(obj.Subject); + long hashCode = RuntimeHelpers.GetHashCode(obj.Subject); hashCode = (hashCode * 397) + RuntimeHelpers.GetHashCode(obj.Expectation); hashCode = (hashCode * 397) + obj.ExpectationIndex; - return hashCode; + return (int)hashCode; } } From fb4295467f3ea347940d65f7ecd4b8b396c17b6d Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 3 May 2026 11:43:11 +0200 Subject: [PATCH 35/43] Add ComparingNullCollectionsAsEmpty and ComparingNullStringsAsEmpty options to BeEquivalentTo Fixes #1938 by introducing two new equivalency options: - ComparingNullCollectionsAsEmpty(): treats null collections as equivalent to empty ones - ComparingNullStringsAsEmpty(): treats null strings as equivalent to empty strings Both options work for direct assertions and when the subject is nested inside an object graph. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CollectionMemberOptionsDecorator.cs | 6 + .../Equivalency/IEquivalencyOptions.cs | 10 ++ .../SelfReferenceEquivalencyOptions.cs | 26 ++++ .../Steps/EnumerableEquivalencyStep.cs | 12 +- .../Steps/GenericEnumerableEquivalencyStep.cs | 21 ++- .../Steps/StringEqualityEquivalencyStep.cs | 15 +- .../Primitives/StringAssertions.cs | 5 +- .../FluentAssertions/net47.verified.txt | 6 + .../FluentAssertions/net6.0.verified.txt | 6 + .../netstandard2.0.verified.txt | 6 + .../netstandard2.1.verified.txt | 6 + .../CollectionSpecs.cs | 136 ++++++++++++++++++ .../StringAssertionSpecs.BeEquivalentTo.cs | 114 +++++++++++++++ docs/_pages/objectgraphs.md | 14 +- docs/_pages/releases.md | 1 + 15 files changed, 373 insertions(+), 11 deletions(-) diff --git a/Src/FluentAssertions/Equivalency/Execution/CollectionMemberOptionsDecorator.cs b/Src/FluentAssertions/Equivalency/Execution/CollectionMemberOptionsDecorator.cs index df6f32a54..a5ba14b3e 100644 --- a/Src/FluentAssertions/Equivalency/Execution/CollectionMemberOptionsDecorator.cs +++ b/Src/FluentAssertions/Equivalency/Execution/CollectionMemberOptionsDecorator.cs @@ -94,6 +94,12 @@ public EqualityStrategy GetEqualityStrategy(Type type) public bool EnableFullDump => inner.EnableFullDump; + /// + public bool TreatNullCollectionsAsEmpty => inner.TreatNullCollectionsAsEmpty; + + /// + public bool TreatNullStringsAsEmpty => inner.TreatNullStringsAsEmpty; + public ITraceWriter TraceWriter => inner.TraceWriter; } diff --git a/Src/FluentAssertions/Equivalency/IEquivalencyOptions.cs b/Src/FluentAssertions/Equivalency/IEquivalencyOptions.cs index 0fbd660e0..03d17a073 100644 --- a/Src/FluentAssertions/Equivalency/IEquivalencyOptions.cs +++ b/Src/FluentAssertions/Equivalency/IEquivalencyOptions.cs @@ -137,4 +137,14 @@ public interface IEquivalencyOptions /// Gets a value indicating whether the full dump of the subject should be included in the failure message. /// bool EnableFullDump { get; } + + /// + /// Gets a value indicating whether null collections should be treated as equivalent to empty collections. + /// + bool TreatNullCollectionsAsEmpty { get; } + + /// + /// Gets a value indicating whether null strings should be treated as equivalent to empty strings. + /// + bool TreatNullStringsAsEmpty { get; } } diff --git a/Src/FluentAssertions/Equivalency/SelfReferenceEquivalencyOptions.cs b/Src/FluentAssertions/Equivalency/SelfReferenceEquivalencyOptions.cs index 40eca97af..00f6348f1 100644 --- a/Src/FluentAssertions/Equivalency/SelfReferenceEquivalencyOptions.cs +++ b/Src/FluentAssertions/Equivalency/SelfReferenceEquivalencyOptions.cs @@ -100,6 +100,8 @@ protected SelfReferenceEquivalencyOptions(IEquivalencyOptions defaults) IgnoreNewlineStyle = defaults.IgnoreNewlineStyle; IncludeFullStringsInDifference = defaults.IncludeFullStringsInDifference; IgnoreJsonPropertyCasing = defaults.IgnoreJsonPropertyCasing; + TreatNullCollectionsAsEmpty = defaults.TreatNullCollectionsAsEmpty; + TreatNullStringsAsEmpty = defaults.TreatNullStringsAsEmpty; ConversionSelector = defaults.ConversionSelector.Clone(); @@ -216,6 +218,10 @@ EqualityStrategy IEquivalencyOptions.GetEqualityStrategy(Type type) /// public bool EnableFullDump { get; private set; } + public bool TreatNullCollectionsAsEmpty { get; private set; } + + public bool TreatNullStringsAsEmpty { get; private set; } + public ITraceWriter TraceWriter { get; private set; } /// @@ -904,6 +910,26 @@ public TSelf IncludingFullStringsInDifference() return (TSelf)this; } + /// + /// Instructs the comparison to treat null collections as equivalent to empty collections, + /// regardless of whether the subject or the expectation is null. + /// + public TSelf ComparingNullCollectionsAsEmpty() + { + TreatNullCollectionsAsEmpty = true; + return (TSelf)this; + } + + /// + /// Instructs the comparison to treat null strings as equivalent to empty strings, + /// regardless of whether the subject or the expectation is null. + /// + public TSelf ComparingNullStringsAsEmpty() + { + TreatNullStringsAsEmpty = true; + return (TSelf)this; + } + #if NET6_0_OR_GREATER /// /// Tells the comparison to ignore the casing when trying to match a property to a JSON property. diff --git a/Src/FluentAssertions/Equivalency/Steps/EnumerableEquivalencyStep.cs b/Src/FluentAssertions/Equivalency/Steps/EnumerableEquivalencyStep.cs index 2a36e04cc..f88ac4078 100644 --- a/Src/FluentAssertions/Equivalency/Steps/EnumerableEquivalencyStep.cs +++ b/Src/FluentAssertions/Equivalency/Steps/EnumerableEquivalencyStep.cs @@ -19,7 +19,13 @@ public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationCon var assertionChain = AssertionChain.GetOrCreate().For(context); - if (AssertSubjectIsCollection(assertionChain, comparands.Subject)) + bool treatNullAsEmpty = context.Options.TreatNullCollectionsAsEmpty; + + bool subjectIsUsable = treatNullAsEmpty + ? comparands.Subject is null || AssertSubjectIsCollection(assertionChain, comparands.Subject) + : AssertSubjectIsCollection(assertionChain, comparands.Subject); + + if (subjectIsUsable) { var validator = new EnumerableEquivalencyValidator(assertionChain, valueChildNodes, context) { @@ -27,7 +33,9 @@ public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationCon OrderingRules = context.Options.OrderingRules }; - validator.Execute(ToArray(comparands.Subject), ToArray(comparands.Expectation)); + validator.Execute( + treatNullAsEmpty && comparands.Subject is null ? [] : ToArray(comparands.Subject), + treatNullAsEmpty && comparands.Expectation is null ? [] : ToArray(comparands.Expectation)); } return EquivalencyResult.EquivalencyProven; diff --git a/Src/FluentAssertions/Equivalency/Steps/GenericEnumerableEquivalencyStep.cs b/Src/FluentAssertions/Equivalency/Steps/GenericEnumerableEquivalencyStep.cs index a736888e9..83935c1ec 100644 --- a/Src/FluentAssertions/Equivalency/Steps/GenericEnumerableEquivalencyStep.cs +++ b/Src/FluentAssertions/Equivalency/Steps/GenericEnumerableEquivalencyStep.cs @@ -22,7 +22,12 @@ public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationCon { Type expectedType = comparands.GetExpectedType(context.Options); - if (comparands.Expectation is null || !IsGenericCollection(expectedType)) + if (!IsGenericCollection(expectedType)) + { + return EquivalencyResult.ContinueWithNext; + } + + if (comparands.Expectation is null && !context.Options.TreatNullCollectionsAsEmpty) { return EquivalencyResult.ContinueWithNext; } @@ -37,7 +42,7 @@ public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationCon "to use for asserting the equivalency of the collection. ", interfaceTypes.Select(type => "IEnumerable<" + type.GetGenericArguments().Single() + ">"))); - if (AssertSubjectIsCollection(assertionChain, comparands.Subject)) + if (AssertSubjectIsCollection(assertionChain, comparands.Subject, context.Options.TreatNullCollectionsAsEmpty)) { var validator = new EnumerableEquivalencyValidator(assertionChain, valueChildNodes, context) { @@ -47,12 +52,13 @@ public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationCon Type typeOfEnumeration = GetTypeOfEnumeration(expectedType); - var subjectAsArray = EnumerableEquivalencyStep.ToArray(comparands.Subject); + var subjectAsArray = comparands.Subject is null ? [] : EnumerableEquivalencyStep.ToArray(comparands.Subject); + object expectation = comparands.Expectation ?? Array.CreateInstance(typeOfEnumeration, 0); try { HandleMethod.MakeGenericMethod(typeOfEnumeration) - .Invoke(null, [validator, subjectAsArray, comparands.Expectation]); + .Invoke(null, [validator, subjectAsArray, expectation]); } catch (TargetInvocationException e) { @@ -66,8 +72,13 @@ public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationCon private static void HandleImpl(EnumerableEquivalencyValidator validator, object[] subject, IEnumerable expectation) => validator.Execute(subject, ToArray(expectation)); - private static bool AssertSubjectIsCollection(AssertionChain assertionChain, object subject) + private static bool AssertSubjectIsCollection(AssertionChain assertionChain, object subject, bool treatNullAsEmpty) { + if (treatNullAsEmpty && subject is null) + { + return true; + } + assertionChain .ForCondition(subject is not null) .FailWith("Expected {context:subject} not to be {0}.", new object[] { null }); diff --git a/Src/FluentAssertions/Equivalency/Steps/StringEqualityEquivalencyStep.cs b/Src/FluentAssertions/Equivalency/Steps/StringEqualityEquivalencyStep.cs index 29136acd5..bf283246d 100644 --- a/Src/FluentAssertions/Equivalency/Steps/StringEqualityEquivalencyStep.cs +++ b/Src/FluentAssertions/Equivalency/Steps/StringEqualityEquivalencyStep.cs @@ -18,7 +18,7 @@ public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationCon var assertionChain = AssertionChain.GetOrCreate().For(context); - if (!ValidateAgainstNulls(assertionChain, comparands, context.CurrentNode)) + if (!ValidateAgainstNulls(assertionChain, comparands, context.CurrentNode, context.Options.TreatNullStringsAsEmpty)) { return EquivalencyResult.EquivalencyProven; } @@ -74,7 +74,8 @@ private static Func, EquivalencyOptions> Crea return o; }; - private static bool ValidateAgainstNulls(AssertionChain assertionChain, Comparands comparands, INode currentNode) + private static bool ValidateAgainstNulls(AssertionChain assertionChain, Comparands comparands, INode currentNode, + bool treatNullAsEmpty) { object expected = comparands.Expectation; object subject = comparands.Subject; @@ -83,6 +84,16 @@ private static bool ValidateAgainstNulls(AssertionChain assertionChain, Comparan if (onlyOneNull) { + if (treatNullAsEmpty) + { + string nonNullValue = (string)(expected ?? subject); + + if (nonNullValue.Length == 0) + { + return false; + } + } + assertionChain.FailWith( "Expected {0} to be {1}{reason}, but found {2}.", currentNode.Subject.Description.AsNonFormattable(), expected, subject); diff --git a/Src/FluentAssertions/Primitives/StringAssertions.cs b/Src/FluentAssertions/Primitives/StringAssertions.cs index 36c59d064..540d75483 100644 --- a/Src/FluentAssertions/Primitives/StringAssertions.cs +++ b/Src/FluentAssertions/Primitives/StringAssertions.cs @@ -156,7 +156,10 @@ public AndConstraint BeEquivalentTo(string expected, new StringEqualityStrategy(options.GetStringComparerOrDefault(), "be equivalent to"), because, becauseArgs); - var subject = ApplyStringSettings(Subject, options); + string subject = options.TreatNullStringsAsEmpty ? Subject ?? "" : Subject; + expected = options.TreatNullStringsAsEmpty ? expected ?? "" : expected; + + subject = ApplyStringSettings(subject, options); expected = ApplyStringSettings(expected, options); expectation.Validate(subject, expected); diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt index 3d478f2ca..abe83d9c6 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt @@ -776,6 +776,8 @@ namespace FluentAssertions.Equivalency FluentAssertions.Equivalency.OrderingRuleCollection OrderingRules { get; } System.Collections.Generic.IEnumerable SelectionRules { get; } FluentAssertions.Equivalency.Tracing.ITraceWriter TraceWriter { get; } + bool TreatNullCollectionsAsEmpty { get; } + bool TreatNullStringsAsEmpty { get; } bool UseRuntimeTyping { get; } System.Collections.Generic.IEnumerable UserEquivalencySteps { get; } FluentAssertions.Equivalency.EqualityStrategy GetEqualityStrategy(System.Type type); @@ -918,6 +920,8 @@ namespace FluentAssertions.Equivalency [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] protected FluentAssertions.Equivalency.OrderingRuleCollection OrderingRules { get; } public FluentAssertions.Equivalency.Tracing.ITraceWriter TraceWriter { get; } + public bool TreatNullCollectionsAsEmpty { get; } + public bool TreatNullStringsAsEmpty { get; } protected TSelf AddMatchingRule(FluentAssertions.Equivalency.IMemberMatchingRule matchingRule) { } protected TSelf AddSelectionRule(FluentAssertions.Equivalency.IMemberSelectionRule selectionRule) { } public TSelf AllowingInfiniteRecursion() { } @@ -927,6 +931,8 @@ namespace FluentAssertions.Equivalency public TSelf ComparingByValue() { } public TSelf ComparingEnumsByName() { } public TSelf ComparingEnumsByValue() { } + public TSelf ComparingNullCollectionsAsEmpty() { } + public TSelf ComparingNullStringsAsEmpty() { } public TSelf ComparingRecordsByMembers() { } public TSelf ComparingRecordsByValue() { } public TSelf Excluding(System.Linq.Expressions.Expression> predicate) { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt index 42d0d6e12..943844768 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt @@ -838,6 +838,8 @@ namespace FluentAssertions.Equivalency FluentAssertions.Equivalency.OrderingRuleCollection OrderingRules { get; } System.Collections.Generic.IEnumerable SelectionRules { get; } FluentAssertions.Equivalency.Tracing.ITraceWriter TraceWriter { get; } + bool TreatNullCollectionsAsEmpty { get; } + bool TreatNullStringsAsEmpty { get; } bool UseRuntimeTyping { get; } System.Collections.Generic.IEnumerable UserEquivalencySteps { get; } FluentAssertions.Equivalency.EqualityStrategy GetEqualityStrategy(System.Type type); @@ -985,6 +987,8 @@ namespace FluentAssertions.Equivalency [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] protected FluentAssertions.Equivalency.OrderingRuleCollection OrderingRules { get; } public FluentAssertions.Equivalency.Tracing.ITraceWriter TraceWriter { get; } + public bool TreatNullCollectionsAsEmpty { get; } + public bool TreatNullStringsAsEmpty { get; } protected TSelf AddMatchingRule(FluentAssertions.Equivalency.IMemberMatchingRule matchingRule) { } protected TSelf AddSelectionRule(FluentAssertions.Equivalency.IMemberSelectionRule selectionRule) { } public TSelf AllowingInfiniteRecursion() { } @@ -994,6 +998,8 @@ namespace FluentAssertions.Equivalency public TSelf ComparingByValue() { } public TSelf ComparingEnumsByName() { } public TSelf ComparingEnumsByValue() { } + public TSelf ComparingNullCollectionsAsEmpty() { } + public TSelf ComparingNullStringsAsEmpty() { } public TSelf ComparingRecordsByMembers() { } public TSelf ComparingRecordsByValue() { } public TSelf Excluding(System.Linq.Expressions.Expression> predicate) { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt index b065facf1..7ced24c3b 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt @@ -768,6 +768,8 @@ namespace FluentAssertions.Equivalency FluentAssertions.Equivalency.OrderingRuleCollection OrderingRules { get; } System.Collections.Generic.IEnumerable SelectionRules { get; } FluentAssertions.Equivalency.Tracing.ITraceWriter TraceWriter { get; } + bool TreatNullCollectionsAsEmpty { get; } + bool TreatNullStringsAsEmpty { get; } bool UseRuntimeTyping { get; } System.Collections.Generic.IEnumerable UserEquivalencySteps { get; } FluentAssertions.Equivalency.EqualityStrategy GetEqualityStrategy(System.Type type); @@ -910,6 +912,8 @@ namespace FluentAssertions.Equivalency [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] protected FluentAssertions.Equivalency.OrderingRuleCollection OrderingRules { get; } public FluentAssertions.Equivalency.Tracing.ITraceWriter TraceWriter { get; } + public bool TreatNullCollectionsAsEmpty { get; } + public bool TreatNullStringsAsEmpty { get; } protected TSelf AddMatchingRule(FluentAssertions.Equivalency.IMemberMatchingRule matchingRule) { } protected TSelf AddSelectionRule(FluentAssertions.Equivalency.IMemberSelectionRule selectionRule) { } public TSelf AllowingInfiniteRecursion() { } @@ -919,6 +923,8 @@ namespace FluentAssertions.Equivalency public TSelf ComparingByValue() { } public TSelf ComparingEnumsByName() { } public TSelf ComparingEnumsByValue() { } + public TSelf ComparingNullCollectionsAsEmpty() { } + public TSelf ComparingNullStringsAsEmpty() { } public TSelf ComparingRecordsByMembers() { } public TSelf ComparingRecordsByValue() { } public TSelf Excluding(System.Linq.Expressions.Expression> predicate) { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt index 08e044e3e..64c73b18c 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt @@ -792,6 +792,8 @@ namespace FluentAssertions.Equivalency FluentAssertions.Equivalency.OrderingRuleCollection OrderingRules { get; } System.Collections.Generic.IEnumerable SelectionRules { get; } FluentAssertions.Equivalency.Tracing.ITraceWriter TraceWriter { get; } + bool TreatNullCollectionsAsEmpty { get; } + bool TreatNullStringsAsEmpty { get; } bool UseRuntimeTyping { get; } System.Collections.Generic.IEnumerable UserEquivalencySteps { get; } FluentAssertions.Equivalency.EqualityStrategy GetEqualityStrategy(System.Type type); @@ -934,6 +936,8 @@ namespace FluentAssertions.Equivalency [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] protected FluentAssertions.Equivalency.OrderingRuleCollection OrderingRules { get; } public FluentAssertions.Equivalency.Tracing.ITraceWriter TraceWriter { get; } + public bool TreatNullCollectionsAsEmpty { get; } + public bool TreatNullStringsAsEmpty { get; } protected TSelf AddMatchingRule(FluentAssertions.Equivalency.IMemberMatchingRule matchingRule) { } protected TSelf AddSelectionRule(FluentAssertions.Equivalency.IMemberSelectionRule selectionRule) { } public TSelf AllowingInfiniteRecursion() { } @@ -943,6 +947,8 @@ namespace FluentAssertions.Equivalency public TSelf ComparingByValue() { } public TSelf ComparingEnumsByName() { } public TSelf ComparingEnumsByValue() { } + public TSelf ComparingNullCollectionsAsEmpty() { } + public TSelf ComparingNullStringsAsEmpty() { } public TSelf ComparingRecordsByMembers() { } public TSelf ComparingRecordsByValue() { } public TSelf Excluding(System.Linq.Expressions.Expression> predicate) { } diff --git a/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs b/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs index c5e910ecb..7d3eec78d 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs @@ -2824,6 +2824,142 @@ ClassWithLotsOfProperties GetObject(int i) act.ExecutionTime().Should().BeLessThan(20.Seconds()); } + public class ComparingNullCollectionsAsEmpty + { + [Fact] + public void A_null_subject_collection_is_equivalent_to_an_empty_expectation() + { + // Arrange + int[] subject = null; + int[] expectation = []; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullCollectionsAsEmpty()); + } + + [Fact] + public void An_empty_subject_collection_is_equivalent_to_a_null_expectation() + { + // Arrange + int[] subject = []; + int[] expectation = null; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullCollectionsAsEmpty()); + } + + [Fact] + public void A_null_subject_collection_fails_when_the_expectation_is_non_empty() + { + // Arrange + int[] subject = null; + int[] expectation = [1, 2, 3]; + + // Act + Action act = () => subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullCollectionsAsEmpty()); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void A_non_empty_subject_collection_fails_when_the_expectation_is_null() + { + // Arrange + int[] subject = [1, 2, 3]; + int[] expectation = null; + + // Act + Action act = () => subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullCollectionsAsEmpty()); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Without_the_option_a_null_subject_collection_is_not_equivalent_to_an_empty_expectation() + { + // Arrange + int[] subject = null; + int[] expectation = []; + + // Act + Action act = () => subject.Should().BeEquivalentTo(expectation); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Without_the_option_an_empty_subject_collection_is_not_equivalent_to_a_null_expectation() + { + // Arrange + int[] subject = []; + int[] expectation = null; + + // Act + Action act = () => subject.Should().BeEquivalentTo(expectation); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void A_null_generic_list_subject_is_equivalent_to_an_empty_generic_list_expectation() + { + // Arrange + List subject = null; + var expectation = new List(); + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullCollectionsAsEmpty()); + } + + [Fact] + public void An_empty_generic_list_subject_is_equivalent_to_a_null_generic_list_expectation() + { + // Arrange + var subject = new List(); + List expectation = null; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullCollectionsAsEmpty()); + } + + [Fact] + public void A_null_nested_collection_is_equivalent_to_an_empty_nested_collection() + { + // Arrange + var subject = new { Items = (int[])null }; + var expectation = new { Items = Array.Empty() }; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullCollectionsAsEmpty()); + } + + [Fact] + public void An_empty_nested_collection_is_equivalent_to_a_null_nested_collection() + { + // Arrange + var subject = new { Items = Array.Empty() }; + var expectation = new { Items = (int[])null }; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullCollectionsAsEmpty()); + } + + [Fact] + public void Two_null_collections_are_equivalent() + { + // Arrange + int[] subject = null; + int[] expectation = null; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullCollectionsAsEmpty()); + } + } + private class ClassWithLotsOfProperties { public string Id { get; set; } diff --git a/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.BeEquivalentTo.cs b/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.BeEquivalentTo.cs index 46787cf39..a073f2a06 100644 --- a/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.BeEquivalentTo.cs +++ b/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.BeEquivalentTo.cs @@ -375,4 +375,118 @@ public void When_the_actual_string_equivalent_to_the_expected_but_with_trailing_ act.Should().NotThrow(); } } + + public class ComparingNullStringsAsEmpty + { + [Fact] + public void A_null_subject_string_is_equivalent_to_an_empty_expectation_string() + { + // Arrange + string subject = null; + string expectation = ""; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullStringsAsEmpty()); + } + + [Fact] + public void An_empty_subject_string_is_equivalent_to_a_null_expectation_string() + { + // Arrange + string subject = ""; + string expectation = null; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullStringsAsEmpty()); + } + + [Fact] + public void Two_null_strings_are_equivalent() + { + // Arrange + string subject = null; + string expectation = null; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullStringsAsEmpty()); + } + + [Fact] + public void A_null_subject_string_fails_when_the_expectation_is_non_empty() + { + // Arrange + string subject = null; + string expectation = "hello"; + + // Act + Action act = () => subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullStringsAsEmpty()); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void A_non_empty_subject_string_fails_when_the_expectation_is_null() + { + // Arrange + string subject = "hello"; + string expectation = null; + + // Act + Action act = () => subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullStringsAsEmpty()); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Without_the_option_a_null_subject_string_is_not_equivalent_to_an_empty_expectation_string() + { + // Arrange + string subject = null; + string expectation = ""; + + // Act + Action act = () => subject.Should().BeEquivalentTo(expectation); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Without_the_option_an_empty_subject_string_is_not_equivalent_to_a_null_expectation_string() + { + // Arrange + string subject = ""; + string expectation = null; + + // Act + Action act = () => subject.Should().BeEquivalentTo(expectation); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void A_null_nested_string_is_equivalent_to_an_empty_nested_string() + { + // Arrange + var subject = new { Name = (string)null }; + var expectation = new { Name = "" }; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullStringsAsEmpty()); + } + + [Fact] + public void An_empty_nested_string_is_equivalent_to_a_null_nested_string() + { + // Arrange + var subject = new { Name = "" }; + var expectation = new { Name = (string)null }; + + // Act / Assert + subject.Should().BeEquivalentTo(expectation, opt => opt.ComparingNullStringsAsEmpty()); + } + } } diff --git a/docs/_pages/objectgraphs.md b/docs/_pages/objectgraphs.md index 86c2dab1f..f6668f519 100644 --- a/docs/_pages/objectgraphs.md +++ b/docs/_pages/objectgraphs.md @@ -396,7 +396,13 @@ string expect = "A\r\nB\nC"; actual.Should().BeEquivalentTo(expect, o => o.IgnoringNewlineStyle()); ``` -Next to that, when two long strings differ, by default the reporting will only include the relevant fragments needed to highlight the differences. If you want to see the full text of both strings, use the `IncludingFullStringsInDifference()` option. +Next to that, when two long strings differ, by default the reporting will only include the relevant fragments needed to highlight the differences. If you want to see the full text of both strings, use the `IncludingFullStringsInDifference()` option. + +You can also treat `null` strings as equivalent to empty strings using `ComparingNullStringsAsEmpty()`: + +```csharp +((string)null).Should().BeEquivalentTo("", o => o.ComparingNullStringsAsEmpty()); +``` ### Enums @@ -424,6 +430,12 @@ You can also assert that all instances of `OrderDto` are structurally equal to a orderDtos.Should().AllBeEquivalentTo(singleOrder); ``` +If you want to treat `null` collections as equivalent to empty collections, use `ComparingNullCollectionsAsEmpty()`: + +```csharp +((int[])null).Should().BeEquivalentTo([], o => o.ComparingNullCollectionsAsEmpty()); +``` + ### JSON For projects targeting .NET 6 or later, you can also compare a `JsonNode` from the `System.Text.Json` namespace representing a deeply nested JSON block against another object such as an anonymous type. You can even use inline assertions like `Value.ThatSatisfies()` and `Value.ThatMatches()`. diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index a916f544f..8bf48bd42 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -18,6 +18,7 @@ sidebar: * Added `HaveMillisecond` and `NotHaveMillisecond` assertion methods for `DateTime` and `DateTimeOffset` - [#3164](https://github.com/fluentassertions/fluentassertions/pull/3164) ### Enhancements +* Added `ComparingNullCollectionsAsEmpty()` and `ComparingNullStringsAsEmpty()` options to `BeEquivalentTo` to treat `null` collections and strings as equivalent to empty ones - [#3202](https://github.com/fluentassertions/fluentassertions/pull/3202) * Added option `WithFullDump` to `BeEquivalentTo` to include the entire contents of the subject-under-test in the failure message - [#3133](https://github.com/fluentassertions/fluentassertions/pull/3133) * Remove FluentAssertions code from the stack trace when an assertion fails - [#3152](https://github.com/fluentassertions/fluentassertions/pull/3152) * Improve reporting the subject when chaining `Throw` with `Which` - [#3160](https://github.com/fluentassertions/fluentassertions/pull/3160) From 0804dbc43d692c2dd529cdedba556178dfe6be55 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Wed, 6 May 2026 13:48:45 +0200 Subject: [PATCH 36/43] Include original index in extraneous item failure messages (#3203) --- .../Steps/EnumerableEquivalencyValidator.cs | 46 +++++++++++++--- .../LooselyOrderedEquivalencyStrategy.cs | 48 ++++++++-------- .../StrictlyOrderedEquivalencyStrategy.cs | 6 +- .../BasicSpecs.cs | 4 +- .../CollectionSpecs.cs | 55 ++++++++++++++++++- ...CollectionAssertionSpecs.BeEquivalentTo.cs | 2 +- docs/_pages/releases.md | 1 + 7 files changed, 123 insertions(+), 39 deletions(-) diff --git a/Src/FluentAssertions/Equivalency/Steps/EnumerableEquivalencyValidator.cs b/Src/FluentAssertions/Equivalency/Steps/EnumerableEquivalencyValidator.cs index 7f9e10445..8e11be3ed 100644 --- a/Src/FluentAssertions/Equivalency/Steps/EnumerableEquivalencyValidator.cs +++ b/Src/FluentAssertions/Equivalency/Steps/EnumerableEquivalencyValidator.cs @@ -4,6 +4,7 @@ using FluentAssertions.Equivalency.Execution; using FluentAssertions.Equivalency.Tracing; using FluentAssertions.Execution; +using FluentAssertions.Formatting; using static FluentAssertions.Common.StringExtensions; namespace FluentAssertions.Equivalency.Steps; @@ -56,10 +57,13 @@ private bool AssertIsNotNull(T[] expectations, object[] subjects) private void ExecuteRecursiveAssertion(object[] subjects, T[] expectation) { - List remainingSubjects = new(subjects); + List> remainingSubjects = subjects + .Select((item, index) => new IndexedItem(item, index)) + .ToList(); + List remainingExpectations = new(expectation); - bool isOrderingStrict = IsOrderingStrictFor(remainingSubjects, remainingExpectations, context.CurrentNode); + bool isOrderingStrict = IsOrderingStrictFor(subjects, remainingExpectations, context.CurrentNode); if (isOrderingStrict) { new StrictlyOrderedEquivalencyStrategy(parent, context) @@ -75,7 +79,7 @@ private void ExecuteRecursiveAssertion(object[] subjects, T[] expectation) isOrderingStrict ? "in order" : "in any order"); } - private bool IsOrderingStrictFor(List subjects, List expectations, INode currentNode) + private bool IsOrderingStrictFor(object[] subjects, List expectations, INode currentNode) { return OrderingRules.IsOrderingStrictFor(new ObjectInfo(new Comparands(subjects, expectations, typeof(T[])), currentNode)); @@ -83,7 +87,7 @@ private bool IsOrderingStrictFor(List subjects, List expectations, #pragma warning disable CA1305 - private void ReportRemainingOrMissingItems(List remainingSubjects, List remainingExpectations, + private void ReportRemainingOrMissingItems(List> remainingSubjects, List remainingExpectations, object[] allSubjects, int expectationLength, string orderingDescription) { @@ -111,8 +115,31 @@ private void ReportRemainingOrMissingItems(List remainingSubjects, Li if (remainingSubjects.Count > 0) { - phrase = remainingSubjects.Count > 1 ? "extraneous items" : "one extraneous item"; - message.Append($"found {phrase} {{1}}"); + if (remainingExpectations.Count == 0) + { + // Subjects are truly extra (the subject collection has more items than expected). + // Show each extraneous item with its original index to help pinpoint the divergence. + if (remainingSubjects.Count == 1) + { + message.Append($"found one extraneous item at index {remainingSubjects[0].Index}: {{1}}"); + } + else + { + string formattedItems = string.Join(", ", + remainingSubjects.Select(s => + $"{Formatter.ToString(s.Item).EscapePlaceholders()} (at index {s.Index})")); + + message.Append("found extraneous items "); + message.Append(formattedItems); + } + } + else + { + // Both missing and extra subjects exist (same-size collections with pairwise mismatches). + // Fall back to the compact list format without per-item indices. + phrase = remainingSubjects.Count > 1 ? "extraneous items" : "one extraneous item"; + message.Append($"found {phrase} {{1}}"); + } } if (context.Options.EnableFullDump) @@ -122,8 +149,11 @@ private void ReportRemainingOrMissingItems(List remainingSubjects, Li message.AppendLine("Full dump of {context:subject}: {2}"); } - assertionChain.FailWith(message.ToString(), remainingExpectations, - remainingSubjects.Count == 1 ? remainingSubjects.Single() : remainingSubjects, allSubjects); + object subjectsArg = remainingSubjects.Count == 1 + ? remainingSubjects[0].Item + : remainingSubjects.Select(s => s.Item).ToList(); + + assertionChain.FailWith(message.ToString(), remainingExpectations, subjectsArg, allSubjects); } } } diff --git a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs index 5bd348c3e..06b37dfbb 100644 --- a/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs +++ b/Src/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs @@ -25,7 +25,7 @@ internal class LooselyOrderedEquivalencyStrategy( // Populated lazily during Phase 3 for the ~n selected pairs with full formatting. private Dictionary<(object Subject, object Expectation, int ExpectationIndex), string[]> fullFailuresCache = new(); - public void FindAndRemoveMatches(List subjects, List expectations) + public void FindAndRemoveMatches(List> subjects, List expectations) { countCache = new(new ReferentialComparer()); fullFailuresCache = new(new ReferentialComparer()); @@ -45,7 +45,7 @@ public void FindAndRemoveMatches(List subjects, List expec expectationsWithIndexes.RemoveMatchedItemFrom(expectations); } - private void FindAndRemoveExactMatches(List subjects, IndexedItemCollection expectationsWithIndexes) + private void FindAndRemoveExactMatches(List> subjects, IndexedItemCollection expectationsWithIndexes) { int expectationIndex = 0; while (expectationsWithIndexes.Count > expectationIndex) @@ -65,14 +65,14 @@ private void FindAndRemoveExactMatches(List subjects, IndexedItemCollect } [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")] - private bool StrictlyMatchAgainst(List remainingSubjects, TExpectation expectation, int expectationIndex) + private bool StrictlyMatchAgainst(List> remainingSubjects, TExpectation expectation, int expectationIndex) { - foreach ((int index, object subject) in remainingSubjects.Index()) + foreach ((int index, IndexedItem subject) in remainingSubjects.Index()) { using var _ = tracer.WriteBlock(member => $"Comparing subject at {member.Subject}[{index}] with the expectation at {member.Expectation}[{expectationIndex}]"); - int failures = TryToMatchCount(expectation, subject, expectationIndex); + int failures = TryToMatchCount(expectation, subject.Item, expectationIndex); if (failures == 0) { tracer.WriteLine(_ => "It's a match"); @@ -91,7 +91,7 @@ private bool StrictlyMatchAgainst(List remainingSubjects, TExpectation e /// that are most similar to remaining subjects first, the algorithm is more likely /// to find the correct pairings earlier, leading to better error messages when mismatches occur. /// - private IndexedItemCollection SortExpectationsByMinDistance(List remainingSubjects, + private IndexedItemCollection SortExpectationsByMinDistance(List> remainingSubjects, IndexedItemCollection expectationsWithIndexes) { if (remainingSubjects.Count > 0) @@ -100,7 +100,7 @@ private IndexedItemCollection SortExpectationsByMinDistance(List new { Expectation = e, - MinDistance = remainingSubjects.Min(a => TryToMatchCount(e.Item, a, e.Index)) + MinDistance = remainingSubjects.Min(a => TryToMatchCount(e.Item, a.Item, e.Index)) }) .OrderBy(x => x.MinDistance) .Select(x => x.Expectation) @@ -112,13 +112,13 @@ private IndexedItemCollection SortExpectationsByMinDistance(List remainingSubjects, + private void FindAndRemoveClosestMatches(List> remainingSubjects, IndexedItemCollection expectationsWithIndexes) { int nrFailures = 0; if (expectationsWithIndexes.Count > 0 && remainingSubjects.Count > 0) { - IReadOnlyList<(IndexedItem, object, string[])> bestMatches = + IReadOnlyList<(IndexedItem, IndexedItem, string[])> bestMatches = FindClosestMismatches(remainingSubjects, expectationsWithIndexes); foreach (var (expectation, subject, failures) in bestMatches) @@ -138,8 +138,8 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, } } - private IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatches( - List remainingSubjects, IndexedItemCollection expectationsWithIndexes) + private IReadOnlyList<(IndexedItem Expectation, IndexedItem Actual, string[] Failures)> FindClosestMismatches( + List> remainingSubjects, IndexedItemCollection expectationsWithIndexes) { // For small collections, use exact permutation search to find the globally optimal assignment. // factorial(8) = 40,320 which is well within reason. @@ -155,13 +155,13 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, /// Uses failure counts for scoring (no string formatting) and only fetches full failure strings for the /// winning assignment. /// - private IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByPermutation( - List remainingSubjects, IndexedItemCollection expectationsWithIndexes) + private IReadOnlyList<(IndexedItem Expectation, IndexedItem Actual, string[] Failures)> FindClosestMismatchesByPermutation( + List> remainingSubjects, IndexedItemCollection expectationsWithIndexes) { var bestScore = int.MaxValue; - IReadOnlyList bestAssignment = null; + IReadOnlyList> bestAssignment = null; - foreach (IReadOnlyList assignment in remainingSubjects.Permute()) + foreach (IReadOnlyList> assignment in remainingSubjects.Permute()) { int score = 0; bool tooHigh = false; @@ -169,7 +169,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, for (int index = 0; index < expectationsWithIndexes.Count && index < assignment.Count; index++) { IndexedItem expectationWithIndex = expectationsWithIndexes[index]; - score += TryToMatchCount(expectationWithIndex.Item, assignment[index], expectationWithIndex.Index); + score += TryToMatchCount(expectationWithIndex.Item, assignment[index].Item, expectationWithIndex.Index); if (score >= bestScore) { @@ -187,17 +187,17 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, if (bestAssignment is null) { - return Array.Empty<(IndexedItem, object, string[])>(); + return Array.Empty<(IndexedItem, IndexedItem, string[])>(); } // Fetch full failure strings only for the winning assignment. int pairCount = Math.Min(expectationsWithIndexes.Count, bestAssignment.Count); - var result = new List<(IndexedItem, object, string[])>(pairCount); + var result = new List<(IndexedItem, IndexedItem, string[])>(pairCount); for (int index = 0; index < pairCount; index++) { IndexedItem expectationWithIndex = expectationsWithIndexes[index]; - string[] failures = TryToMatch(expectationWithIndex.Item, bestAssignment[index], expectationWithIndex.Index); + string[] failures = TryToMatch(expectationWithIndex.Item, bestAssignment[index].Item, expectationWithIndex.Index); result.Add((expectationWithIndex, bestAssignment[index], failures)); } @@ -209,8 +209,8 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, /// permutation search would be prohibitively expensive. All distances are already cached from Phase 1, so /// this is O(n² log n) rather than O(n! × n). /// - private IReadOnlyList<(IndexedItem Expectation, object Actual, string[] Failures)> FindClosestMismatchesByGreedyAssignment( - List remainingSubjects, IndexedItemCollection expectationsWithIndexes) + private IReadOnlyList<(IndexedItem Expectation, IndexedItem Actual, string[] Failures)> FindClosestMismatchesByGreedyAssignment( + List> remainingSubjects, IndexedItemCollection expectationsWithIndexes) { int subjectCount = remainingSubjects.Count; int expectationCount = expectationsWithIndexes.Count; @@ -225,7 +225,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, for (int subjectIndex = 0; subjectIndex < subjectCount; subjectIndex++) { - int count = TryToMatchCount(exp.Item, remainingSubjects[subjectIndex], exp.Index); + int count = TryToMatchCount(exp.Item, remainingSubjects[subjectIndex].Item, exp.Index); allPairs.Add((expectationIndex, subjectIndex, count)); } } @@ -248,7 +248,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, var assignedSubjectIndexes = new bool[subjectCount]; int totalToAssign = Math.Min(expectationCount, subjectCount); - var result = new List<(IndexedItem, object, string[])>(totalToAssign); + var result = new List<(IndexedItem, IndexedItem, string[])>(totalToAssign); // First checks candidate matches from best to worst, then picks the first unused expectation/subject pair it finds, // computes detailed failures only for that chosen pair, and then repeats until all possible matches are assigned. @@ -258,7 +258,7 @@ private void FindAndRemoveClosestMatches(List remainingSubjects, { // Fetch full failure strings only for the selected pairs (~n total). string[] failures = TryToMatch(expectationsWithIndexes[expectationIndex].Item, - remainingSubjects[subjectIndex], expectationsWithIndexes[expectationIndex].Index); + remainingSubjects[subjectIndex].Item, expectationsWithIndexes[expectationIndex].Index); result.Add((expectationsWithIndexes[expectationIndex], remainingSubjects[subjectIndex], failures)); assignedExpectationIndexes[expectationIndex] = true; diff --git a/Src/FluentAssertions/Equivalency/Steps/StrictlyOrderedEquivalencyStrategy.cs b/Src/FluentAssertions/Equivalency/Steps/StrictlyOrderedEquivalencyStrategy.cs index b8ac03881..809a41bad 100644 --- a/Src/FluentAssertions/Equivalency/Steps/StrictlyOrderedEquivalencyStrategy.cs +++ b/Src/FluentAssertions/Equivalency/Steps/StrictlyOrderedEquivalencyStrategy.cs @@ -13,7 +13,7 @@ internal class StrictlyOrderedEquivalencyStrategy( private const int FailedItemsFastFailThreshold = 10; private readonly Tracer tracer = context.Tracer; - public void FindAndRemoveMatches(List subjects, List expectations) + public void FindAndRemoveMatches(List> subjects, List expectations) { int failedCount = 0; @@ -44,11 +44,11 @@ public void FindAndRemoveMatches(List subjects, List expec expectations.RemoveRange(0, index); } - private bool StrictlyMatchAgainst(List subjects, T expectation, int expectationIndex) + private bool StrictlyMatchAgainst(List> subjects, T expectation, int expectationIndex) { using var scope = new AssertionScope(); - object subject = subjects[expectationIndex]; + object subject = subjects[expectationIndex].Item; IEquivalencyValidationContext equivalencyValidationContext = context.AsCollectionItem(expectationIndex); parent.AssertEquivalencyOf(new Comparands(subject, expectation, typeof(T)), equivalencyValidationContext); diff --git a/Tests/FluentAssertions.Equivalency.Specs/BasicSpecs.cs b/Tests/FluentAssertions.Equivalency.Specs/BasicSpecs.cs index a09059ff5..3baaae2c0 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/BasicSpecs.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/BasicSpecs.cs @@ -950,7 +950,7 @@ public void Reports_all_relevant_details_to_understand_the_differences() """ Expected property actual[0].Name to be "Dennis" with a length of 6, but "Jits" has a length of 4, differs near "Jit" (index 0). Expected property actual[0].Age to be 52, but found 13. - Expected actual to contain exactly one item, but found one extraneous item FluentAssertions.Equivalency.Specs.Customer + Expected actual to contain exactly one item, but found one extraneous item at index 1: FluentAssertions.Equivalency.Specs.Customer { Age = 16, Birthdate = <0001-01-01 00:00:00.000>, @@ -1006,7 +1006,7 @@ public void Reports_all_relevant_details_to_understand_the_differences_including """ Expected property actual[0].Name to be "Dennis" with a length of 6, but "Jits" has a length of 4, differs near "Jit" (index 0). Expected property actual[0].Age to be 52, but found 13. - Expected actual to contain exactly one item, but found one extraneous item FluentAssertions.Equivalency.Specs.Customer + Expected actual to contain exactly one item, but found one extraneous item at index 1: FluentAssertions.Equivalency.Specs.Customer { Age = 16, Birthdate = <0001-01-01 00:00:00.000>, diff --git a/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs b/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs index 7d3eec78d..47ef46c99 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs @@ -2222,7 +2222,60 @@ public void When_the_subject_contains_more_items_than_expected_it_should_throw() // Assert action.Should().Throw() .WithMessage( - "*Expected subject to contain exactly one item, but found one extraneous item*Age = 24*"); + "*Expected subject to contain exactly one item, but found one extraneous item at index 1:*Age = 24*"); + } + + [Fact] + public void When_the_subject_contains_multiple_extra_items_it_should_include_the_index_of_each() + { + // Arrange + var subject = new List + { + new() { Name = "John", Age = 27, Id = 1 }, + new() { Name = "Jane", Age = 24, Id = 2 }, + new() { Name = "Bob", Age = 32, Id = 3 } + }; + + var expectation = new List + { + new() { Name = "John", Age = 27, Id = 1 } + }; + + // Act + Action action = + () => subject.Should().BeEquivalentTo(expectation); + + // Assert + action.Should().Throw() + .WithMessage("*extraneous items *at index 1*at index 2*"); + } + + [Fact] + public void When_the_subject_contains_multiple_extra_items_the_failure_message_should_include_their_formatted_properties() + { + // Arrange - Customer objects produce formatted output containing curly braces (e.g. "Customer\r\n{\r\n Age = 24\r\n}") + // which must be escaped before embedding into the FailWith format string to prevent string.Format from + // interpreting them as placeholders and producing a **WARNING** instead of the actual failure message. + var subject = new List + { + new() { Name = "John", Age = 27, Id = 1 }, + new() { Name = "Jane", Age = 24, Id = 2 }, + new() { Name = "Bob", Age = 32, Id = 3 } + }; + + var expectation = new List + { + new() { Name = "John", Age = 27, Id = 1 } + }; + + // Act + Action action = + () => subject.Should().BeEquivalentTo(expectation); + + // Assert + action.Should().Throw() + .WithMessage("*extraneous items*Age = 24*at index 1*Age = 32*at index 2*") + .WithoutMessage("**WARNING**"); } [Fact] diff --git a/Tests/FluentAssertions.Specs/Collections/CollectionAssertionSpecs.BeEquivalentTo.cs b/Tests/FluentAssertions.Specs/Collections/CollectionAssertionSpecs.BeEquivalentTo.cs index cf82eecbe..f094c3ffd 100644 --- a/Tests/FluentAssertions.Specs/Collections/CollectionAssertionSpecs.BeEquivalentTo.cs +++ b/Tests/FluentAssertions.Specs/Collections/CollectionAssertionSpecs.BeEquivalentTo.cs @@ -58,7 +58,7 @@ public void When_collections_are_not_equivalent_it_should_throw() // Assert act.Should().Throw().WithMessage( - "Expected collection1*2 items*we treat all alike, but*extraneous item 3*"); + "Expected collection1*2 items*we treat all alike, but*extraneous item*3*"); } [Fact] diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index 8bf48bd42..24c5551ee 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -19,6 +19,7 @@ sidebar: ### Enhancements * Added `ComparingNullCollectionsAsEmpty()` and `ComparingNullStringsAsEmpty()` options to `BeEquivalentTo` to treat `null` collections and strings as equivalent to empty ones - [#3202](https://github.com/fluentassertions/fluentassertions/pull/3202) +* `BeEquivalentTo` now includes the original index of each extraneous item in the failure message - [#3203](https://github.com/fluentassertions/fluentassertions/pull/3203) * Added option `WithFullDump` to `BeEquivalentTo` to include the entire contents of the subject-under-test in the failure message - [#3133](https://github.com/fluentassertions/fluentassertions/pull/3133) * Remove FluentAssertions code from the stack trace when an assertion fails - [#3152](https://github.com/fluentassertions/fluentassertions/pull/3152) * Improve reporting the subject when chaining `Throw` with `Which` - [#3160](https://github.com/fluentassertions/fluentassertions/pull/3160) From a36d71ca37e3072172063e5d95cb5a2c0f5f4265 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Tue, 12 May 2026 07:21:37 +0200 Subject: [PATCH 37/43] Fix Qodana overflow warning in ReferentialComparer using unchecked context Use unchecked arithmetic with int (the standard C# hash code pattern) instead of long, which still triggers Qodana's overflow inspection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Equivalency/Steps/ReferentialComparer.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs b/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs index 1d69a05e5..ffaedd60f 100644 --- a/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs +++ b/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs @@ -20,9 +20,12 @@ public bool Equals((object Subject, object Expectation, int ExpectationIndex) x, public int GetHashCode((object Subject, object Expectation, int ExpectationIndex) obj) { - long hashCode = RuntimeHelpers.GetHashCode(obj.Subject); - hashCode = (hashCode * 397) + RuntimeHelpers.GetHashCode(obj.Expectation); - hashCode = (hashCode * 397) + obj.ExpectationIndex; - return (int)hashCode; + unchecked + { + int hashCode = RuntimeHelpers.GetHashCode(obj.Subject); + hashCode = (hashCode * 397) + RuntimeHelpers.GetHashCode(obj.Expectation); + hashCode = (hashCode * 397) + obj.ExpectationIndex; + return hashCode; + } } } From 2daa84f8a06b302d9c555ef6a458f3af506dcc5d Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Tue, 12 May 2026 08:31:10 +0200 Subject: [PATCH 38/43] Fix failing pipeline - Revert ReferentialComparer hash code to original int-based implementation that matches the Qodana baseline (was incorrectly changed to long/unchecked) - Upgrade SharpCompress from 0.47.4 to 1.0.0 to fix NU1902 vulnerability (GHSA-6c8g-7p36-r338) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Build/_build.csproj | 2 +- .../Equivalency/Steps/ReferentialComparer.cs | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Build/_build.csproj b/Build/_build.csproj index 46dbdb457..71e355977 100644 --- a/Build/_build.csproj +++ b/Build/_build.csproj @@ -18,7 +18,7 @@ - + diff --git a/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs b/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs index ffaedd60f..6b8241bab 100644 --- a/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs +++ b/Src/FluentAssertions/Equivalency/Steps/ReferentialComparer.cs @@ -20,12 +20,9 @@ public bool Equals((object Subject, object Expectation, int ExpectationIndex) x, public int GetHashCode((object Subject, object Expectation, int ExpectationIndex) obj) { - unchecked - { - int hashCode = RuntimeHelpers.GetHashCode(obj.Subject); - hashCode = (hashCode * 397) + RuntimeHelpers.GetHashCode(obj.Expectation); - hashCode = (hashCode * 397) + obj.ExpectationIndex; - return hashCode; - } + int hashCode = RuntimeHelpers.GetHashCode(obj.Subject); + hashCode = (hashCode * 397) + RuntimeHelpers.GetHashCode(obj.Expectation); + hashCode = (hashCode * 397) + obj.ExpectationIndex; + return hashCode; } } From 43524ef4592147ecabcc21b3df97a9e97c30d610 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Tue, 12 May 2026 08:39:04 +0200 Subject: [PATCH 39/43] Update SharpCompress API usage for 1.0.0 breaking change ReaderFactory.OpenReader(Stream) was renamed to ReaderFactory.Open(Stream, ReaderOptions) in SharpCompress 1.0.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Build/CompressionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Build/CompressionExtensions.cs b/Build/CompressionExtensions.cs index 89753805b..649eb2bb3 100644 --- a/Build/CompressionExtensions.cs +++ b/Build/CompressionExtensions.cs @@ -9,7 +9,7 @@ public static void UnTarXzTo(this AbsolutePath archive, AbsolutePath directory) { using Stream stream = File.OpenRead(archive); - using var reader = ReaderFactory.OpenReader(stream); + using var reader = ReaderFactory.Open(stream, new ReaderOptions()); while (reader.MoveToNextEntry()) { From 33d12d151aa7b8be58e6b98e7e46d7df91cad6e6 Mon Sep 17 00:00:00 2001 From: Jonas Nyrup Date: Tue, 12 May 2026 08:59:03 +0200 Subject: [PATCH 40/43] Downgrade SharpCompress 1.0.0 -> 0.48.0 1.0.0 is unlisted on nuget.org --- Build/CompressionExtensions.cs | 2 +- Build/_build.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Build/CompressionExtensions.cs b/Build/CompressionExtensions.cs index 649eb2bb3..89753805b 100644 --- a/Build/CompressionExtensions.cs +++ b/Build/CompressionExtensions.cs @@ -9,7 +9,7 @@ public static void UnTarXzTo(this AbsolutePath archive, AbsolutePath directory) { using Stream stream = File.OpenRead(archive); - using var reader = ReaderFactory.Open(stream, new ReaderOptions()); + using var reader = ReaderFactory.OpenReader(stream); while (reader.MoveToNextEntry()) { diff --git a/Build/_build.csproj b/Build/_build.csproj index 71e355977..6d7e45f81 100644 --- a/Build/_build.csproj +++ b/Build/_build.csproj @@ -18,7 +18,7 @@ - + From bf5cafed1b35931206b26216f9a9d67cb72d5105 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 07:35:23 +0000 Subject: [PATCH 41/43] Bump NUnit Bumps NUnit to 4.6.0 --- updated-dependencies: - dependency-name: NUnit dependency-version: 4.6.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: nunit - dependency-name: NUnit dependency-version: 4.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nunit - dependency-name: NUnit3TestAdapter dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: nunit - dependency-name: NUnit3TestAdapter dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: nunit ... Signed-off-by: dependabot[bot] --- Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj b/Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj index f606846da..38663aa3a 100644 --- a/Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj +++ b/Tests/TestFrameworks/NUnit4.Specs/NUnit4.Specs.csproj @@ -12,7 +12,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 1c22299fbb374d544f7fe9fde0a8ec3f851a4e1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 07:34:12 +0000 Subject: [PATCH 42/43] Bump MSTest.TestAdapter and MSTest.TestFramework Bumps MSTest.TestAdapter to 4.2.2 Bumps MSTest.TestFramework to 4.2.2 --- updated-dependencies: - dependency-name: MSTest.TestAdapter dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-major dependency-group: mstest - dependency-name: MSTest.TestAdapter dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: mstest - dependency-name: MSTest.TestFramework dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-major dependency-group: mstest - dependency-name: MSTest.TestFramework dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: mstest ... Signed-off-by: dependabot[bot] --- Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj b/Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj index bc4de568b..dc27b69b7 100644 --- a/Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj +++ b/Tests/TestFrameworks/MSTestV4.Specs/MSTestV4.Specs.csproj @@ -12,7 +12,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + From 0954811776a71005283221b91aabafc5fec332b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 07:35:42 +0000 Subject: [PATCH 43/43] Bump Verify.XunitV3 Bumps Verify.XunitV3 from 31.16.1 to 31.16.3 --- updated-dependencies: - dependency-name: Verify.XunitV3 dependency-version: 31.16.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: xunit - dependency-name: Xunit.StaFact dependency-version: 3.0.13 dependency-type: direct:production update-type: version-update:semver-major dependency-group: xunit - dependency-name: Xunit.StaFact dependency-version: 3.0.13 dependency-type: direct:production update-type: version-update:semver-major dependency-group: xunit ... Signed-off-by: dependabot[bot] --- Tests/Approval.Tests/Approval.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Approval.Tests/Approval.Tests.csproj b/Tests/Approval.Tests/Approval.Tests.csproj index ba378957c..112bf8a4d 100644 --- a/Tests/Approval.Tests/Approval.Tests.csproj +++ b/Tests/Approval.Tests/Approval.Tests.csproj @@ -14,7 +14,7 @@ - +