diff --git a/README.md b/README.md index 4887491..f6b584d 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,107 @@ 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. + +#### 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. @@ -405,10 +506,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))); + } + } +}