From 4bdb221283d4b9459ca0a88f4df956df0a49f37d Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 29 Apr 2026 09:50:56 +0200 Subject: [PATCH 1/2] feat: add TestFixAll method to CodeFixTestFixture Add FixAll testing support to catch bugs where individual code fixes work but applying all fixes simultaneously fails (e.g., overlapping text changes silently dropped by BatchFixer). New public API: - CodeFixTestFixture.TestFixAll(markupCode, expected, diagnosticId) - CodeFixTestFixture.TestFixAll(markupCode, expected, descriptor) Implementation: - TestDiagnosticProvider: implements FixAllContext.DiagnosticProvider for feeding pre-computed diagnostics to the FixAll infrastructure - Auto-detects EquivalenceKey from first diagnostic's CodeAction - Asserts marker count matches diagnostic count - Uses FixAllScope.Document, tests the CodeFix's real FixAllProvider Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 79 ++++++++++++++++++++ src/RoslynTestKit/CodeFixTestFixture.cs | 83 +++++++++++++++++++++ src/RoslynTestKit/RoslynTestKitException.cs | 27 +++++++ src/RoslynTestKit/TestDiagnosticProvider.cs | 45 +++++++++++ 4 files changed, 234 insertions(+) create mode 100644 src/RoslynTestKit/TestDiagnosticProvider.cs diff --git a/README.md b/README.md index 4887491..995bb7c 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,80 @@ public async Task HasFix(string testCase) } ``` +#### Example: Test FixAll behavior + +FixAll tests verify that all diagnostics from a given rule can be fixed simultaneously using the CodeFix's `FixAllProvider`. This catches a class of bugs where individual fixes work fine, but applying all fixes at once fails due to overlapping text changes. + +Create two files: `current.al` with `[| |]` markers at **every** expected diagnostic location, and `expected.al` with the fully-fixed output. + +##### current.al + +```AL +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + [|field(2; FirstCalcField; Boolean)|] + { + FieldClass = FlowField; + CalcFormula = exist(MyTable where (MyField = field(MyField))); + } + [|field(3; SecondCalcField; Boolean)|] + { + FieldClass = FlowField; + CalcFormula = exist(MyTable where (MyField = field(MyField))); + } + } +} +``` + +##### expected.al + +```AL +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + field(2; FirstCalcField; Boolean) + { + FieldClass = FlowField; + CalcFormula = exist(MyTable where (MyField = field(MyField))); + Editable = false; + } + field(3; SecondCalcField; Boolean) + { + FieldClass = FlowField; + CalcFormula = exist(MyTable where (MyField = field(MyField))); + Editable = false; + } + } +} +``` + +Create a C# class to execute the tests. + +```C# +[Test] +[TestCase("MultipleFlowFieldsAreEditable")] +public async Task HasFixAll(string testCase) +{ + var currentCode = await File.ReadAllTextAsync("current.al").ConfigureAwait(false); + var expectedCode = await File.ReadAllTextAsync("expected.al").ConfigureAwait(false); + + var fixture = RoslynFixtureFactory.Create( + new CodeFixTestFixtureConfig + { + AdditionalAnalyzers = [new Analyzer.FlowFieldsShouldNotBeEditable()] + }); + + fixture.TestFixAll(currentCode, expectedCode, DiagnosticDescriptors.FlowFieldsShouldNotBeEditable); +} +``` + +> **Note:** The number of `[| |]` markers in `current.al` must exactly match the number of diagnostics the analyzer reports. If there is a mismatch, the test throws a `RoslynTestKitException` with details about the diagnostics found. The `EquivalenceKey` for the FixAll operation is auto-detected from the first diagnostic's code fix. If you need to select a specific fix (when a CodeFix registers multiple actions), pass the `equivalenceKey` parameter explicitly. + ### Fixture configuration Every `RoslynFixtureFactory.Create()` overload accepts an optional config object. The config classes share a common base (`BaseTestFixtureConfig`) that exposes project-level settings. Fixture-specific config classes inherit from this base and can add extra options on top. @@ -405,10 +479,15 @@ Working with `.al` files instead of declaring the code inline the test method it │ │ │ └───SingleFlowFieldIsEditableWithComment │ │ │ │ ├───current.al │ │ │ │ └───expected.al +│ │ └───HasFixAll +│ │ │ └───MultipleFlowFieldsAreEditable +│ │ │ │ ├───current.al +│ │ │ │ └───expected.al │ ├───MyOtherDiagnostic │ │ ├───HasDiagnostic │ │ └───NoDiagnostic │ │ └───HasFix +│ │ └───HasFixAll ``` ## Code comparison diff --git a/src/RoslynTestKit/CodeFixTestFixture.cs b/src/RoslynTestKit/CodeFixTestFixture.cs index cbe71b6..7e83620 100644 --- a/src/RoslynTestKit/CodeFixTestFixture.cs +++ b/src/RoslynTestKit/CodeFixTestFixture.cs @@ -11,6 +11,7 @@ using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces; using RoslynTestKit.CodeActionLocators; using RoslynTestKit.Utils; +using Document = Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.Document; namespace RoslynTestKit { @@ -104,6 +105,88 @@ public void TestCodeFix(Document document, string expected, DiagnosticDescriptor TestCodeFix(document, expected, diagnostic, locator, new ByIndexCodeActionSelector(codeFixIndex)); } + /// + /// Tests the FixAll operation for a given diagnostic ID. The markup code must contain + /// multiple [| |] markers, one for each expected diagnostic. The method asserts that + /// the analyzer produces exactly as many diagnostics as there are markers, then invokes the + /// CodeFix's at scope and + /// compares the result against the expected code. + /// + /// Code with [| |] markers at each expected diagnostic location. + /// The expected code after all fixes have been applied. + /// The diagnostic ID to fix (e.g. "AC0031"). + /// Index of the code fix to select for equivalence key auto-detection (default: 0). + /// Optional explicit equivalence key. When null, auto-detected from the first diagnostic's code fix. + public void TestFixAll(string markupCode, string expected, string diagnosticId, int codeFixIndex = 0, string? equivalenceKey = null) + { + var markup = new CodeMarkup(markupCode); + var document = CreateDocumentFromCode(markup.Code); + var allDiagnostics = GetAllReportedDiagnostics(document).ToList(); + var matchingDiagnostics = allDiagnostics.Where(d => d.Id == diagnosticId).ToList(); + + if (matchingDiagnostics.Count != markup.AllLocators.Count) + { + throw RoslynTestKitException.FixAllDiagnosticCountMismatch( + markup.AllLocators.Count, matchingDiagnostics.Count, diagnosticId, matchingDiagnostics); + } + + TestFixAll(document, expected, matchingDiagnostics, codeFixIndex, equivalenceKey); + } + + /// + /// Tests the FixAll operation for a given . The markup code must + /// contain multiple [| |] markers, one for each expected diagnostic. + /// + /// Code with [| |] markers at each expected diagnostic location. + /// The expected code after all fixes have been applied. + /// The diagnostic descriptor to fix. + /// Index of the code fix to select for equivalence key auto-detection (default: 0). + /// Optional explicit equivalence key. When null, auto-detected from the first diagnostic's code fix. + public void TestFixAll(string markupCode, string expected, DiagnosticDescriptor descriptor, int codeFixIndex = 0, string? equivalenceKey = null) + { + TestFixAll(markupCode, expected, descriptor.Id, codeFixIndex, equivalenceKey); + } + + private void TestFixAll(Document document, string expected, IReadOnlyList diagnostics, int codeFixIndex, string? equivalenceKey) + { + var provider = CreateProvider(); + var fixAllProvider = provider.GetFixAllProvider(); + if (fixAllProvider is null) + { + throw RoslynTestKitException.FixAllProviderNotFound(provider.GetType().Name); + } + + if (equivalenceKey is null) + { + var firstDiagnostic = diagnostics[0]; + var codeFixes = GetCodeFixes(document, firstDiagnostic); + if (codeFixes.Length > codeFixIndex) + { + equivalenceKey = codeFixes[codeFixIndex].EquivalenceKey; + } + } + + var diagnosticIds = diagnostics.Select(d => d.Id).Distinct(); + var diagnosticProvider = new TestDiagnosticProvider(diagnostics.ToImmutableArray()); + + var fixAllContext = new FixAllContext( + document, + provider, + FixAllScope.Document, + equivalenceKey, + diagnosticIds, + diagnosticProvider, + CancellationToken.None); + + var fixAllAction = fixAllProvider.GetFixAsync(fixAllContext).GetAwaiter().GetResult(); + if (fixAllAction is null) + { + throw RoslynTestKitException.FixAllReturnedNoAction(equivalenceKey); + } + + Verify.CodeAction(fixAllAction, document, expected); + } + private void TestCodeFix(Document document, string expected, string diagnosticId, IDiagnosticLocator locator, ICodeActionSelector codeActionSelector) { var diagnostic = GetDiagnostic(document, diagnosticId, locator); diff --git a/src/RoslynTestKit/RoslynTestKitException.cs b/src/RoslynTestKit/RoslynTestKitException.cs index ad23826..cc425cd 100644 --- a/src/RoslynTestKit/RoslynTestKitException.cs +++ b/src/RoslynTestKit/RoslynTestKitException.cs @@ -108,5 +108,32 @@ public static Exception CannotFindSuggestion(IReadOnlyList missingComple var foundSuggestionDescription = resultItems.MergeAsBulletList(x => x.DisplayText, title: "\r\nFound suggestions:\r\n"); return new RoslynTestKitException($"Cannot get suggestions:\r\n{missingCompletion.MergeAsBulletList()}\r\nat{locator.Description()}{foundSuggestionDescription}"); } + + public static RoslynTestKitException FixAllProviderNotFound(string codeFixProviderName) + { + return new RoslynTestKitException( + $"CodeFixProvider '{codeFixProviderName}' does not support FixAll (GetFixAllProvider() returned null). " + + "Ensure the CodeFixProvider overrides GetFixAllProvider() and returns a non-null FixAllProvider."); + } + + public static RoslynTestKitException FixAllDiagnosticCountMismatch(int expectedCount, int actualCount, string diagnosticId, IReadOnlyList foundDiagnostics) + { + var diagnosticInfo = foundDiagnostics.MergeWithNewLines(d => + { + var position = d.Location.GetLineSpan().StartLinePosition; + return $" [{d.Id}] Line:{position.Line} Col:{position.Character} - {d.GetMessage()}"; + }); + return new RoslynTestKitException( + $"Expected {expectedCount} diagnostics for '{diagnosticId}' (based on [| |] markers) but found {actualCount}.\r\n" + + $"Diagnostics found:\r\n{diagnosticInfo}"); + } + + public static RoslynTestKitException FixAllReturnedNoAction(string? equivalenceKey) + { + var keyInfo = equivalenceKey != null ? $" with equivalence key '{equivalenceKey}'" : ""; + return new RoslynTestKitException( + $"FixAllProvider.GetFixAsync() returned null{keyInfo}. " + + "The FixAll operation produced no code action."); + } } } \ No newline at end of file diff --git a/src/RoslynTestKit/TestDiagnosticProvider.cs b/src/RoslynTestKit/TestDiagnosticProvider.cs new file mode 100644 index 0000000..b82dda7 --- /dev/null +++ b/src/RoslynTestKit/TestDiagnosticProvider.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Dynamics.Nav.CodeAnalysis.CodeFixes; +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces; +using Document = Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.Document; + +namespace RoslynTestKit +{ + internal sealed class TestDiagnosticProvider : FixAllContext.DiagnosticProvider + { + private readonly ImmutableArray _diagnostics; + + public TestDiagnosticProvider(ImmutableArray diagnostics) + { + _diagnostics = diagnostics; + } + + public override async Task> GetDocumentDiagnosticsAsync( + Document document, ISet diagnosticIdsWithFixes, CancellationToken cancellationToken) + { + var tree = await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); + return _diagnostics.Where(d => + d.Location.IsInSource && + d.Location.SourceTree == tree && + diagnosticIdsWithFixes.Contains(d.Id)); + } + + public override Task> GetProjectDiagnosticsAsync( + Project project, ISet diagnosticIdsWithFixes, CancellationToken cancellationToken) + { + return Task.FromResult(Enumerable.Empty()); + } + + public override Task> GetAllDiagnosticsAsync( + Project project, ISet diagnosticIdsWithFixes, CancellationToken cancellationToken) + { + return Task.FromResult>( + _diagnostics.Where(d => diagnosticIdsWithFixes.Contains(d.Id))); + } + } +} From c9622e208a4662ac550b7a567ddbf6b5bed978b4 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 29 Apr 2026 09:55:32 +0200 Subject: [PATCH 2/2] docs: add equivalenceKey override example to README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 995bb7c..f6b584d 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,33 @@ public async Task HasFixAll(string testCase) > **Note:** The number of `[| |]` markers in `current.al` must exactly match the number of diagnostics the analyzer reports. If there is a mismatch, the test throws a `RoslynTestKitException` with details about the diagnostics found. The `EquivalenceKey` for the FixAll operation is auto-detected from the first diagnostic's code fix. If you need to select a specific fix (when a CodeFix registers multiple actions), pass the `equivalenceKey` parameter explicitly. +#### Example: FixAll with explicit equivalence key + +When a CodeFix registers multiple actions for the same diagnostic (e.g., "Remove unused permission" vs. "Mark as used"), pass the `equivalenceKey` to select which fix to apply across all diagnostics. + +```C# +[Test] +public async Task HasFixAll_SpecificAction(string testCase) +{ + var currentCode = await File.ReadAllTextAsync("current.al").ConfigureAwait(false); + var expectedCode = await File.ReadAllTextAsync("expected.al").ConfigureAwait(false); + + var fixture = RoslynFixtureFactory.Create( + new CodeFixTestFixtureConfig + { + AdditionalAnalyzers = [new Analyzer.MyDiagnosticAnalyzer()] + }); + + fixture.TestFixAll( + currentCode, + expectedCode, + DiagnosticDescriptors.MyRule, + equivalenceKey: "RemoveUnusedPermission"); +} +``` + +> **Tip:** The `equivalenceKey` value must match the `CodeAction.EquivalenceKey` set by the CodeFix when registering the action via `context.RegisterCodeFix()`. If unsure, run a single `TestCodeFix` first and inspect the available code actions. + ### Fixture configuration Every `RoslynFixtureFactory.Create()` overload accepts an optional config object. The config classes share a common base (`BaseTestFixtureConfig`) that exposes project-level settings. Fixture-specific config classes inherit from this base and can add extra options on top.