Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlowFieldsShouldNotBeEditableCodeFixProvider>(
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<MyCodeFixProvider>(
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<T>()` 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.
Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions src/RoslynTestKit/CodeFixTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -104,6 +105,88 @@ public void TestCodeFix(Document document, string expected, DiagnosticDescriptor
TestCodeFix(document, expected, diagnostic, locator, new ByIndexCodeActionSelector(codeFixIndex));
}

/// <summary>
/// Tests the FixAll operation for a given diagnostic ID. The markup code must contain
/// multiple <c>[| |]</c> 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 <see cref="FixAllProvider"/> at <see cref="FixAllScope.Document"/> scope and
/// compares the result against the expected code.
/// </summary>
/// <param name="markupCode">Code with <c>[| |]</c> markers at each expected diagnostic location.</param>
/// <param name="expected">The expected code after all fixes have been applied.</param>
/// <param name="diagnosticId">The diagnostic ID to fix (e.g. "AC0031").</param>
/// <param name="codeFixIndex">Index of the code fix to select for equivalence key auto-detection (default: 0).</param>
/// <param name="equivalenceKey">Optional explicit equivalence key. When null, auto-detected from the first diagnostic's code fix.</param>
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);
}

/// <summary>
/// Tests the FixAll operation for a given <see cref="DiagnosticDescriptor"/>. The markup code must
/// contain multiple <c>[| |]</c> markers, one for each expected diagnostic.
/// </summary>
/// <param name="markupCode">Code with <c>[| |]</c> markers at each expected diagnostic location.</param>
/// <param name="expected">The expected code after all fixes have been applied.</param>
/// <param name="descriptor">The diagnostic descriptor to fix.</param>
/// <param name="codeFixIndex">Index of the code fix to select for equivalence key auto-detection (default: 0).</param>
/// <param name="equivalenceKey">Optional explicit equivalence key. When null, auto-detected from the first diagnostic's code fix.</param>
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<Diagnostic> 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);
Expand Down
27 changes: 27 additions & 0 deletions src/RoslynTestKit/RoslynTestKitException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,32 @@ public static Exception CannotFindSuggestion(IReadOnlyList<string> 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<Diagnostic> 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.");
}
}
}
45 changes: 45 additions & 0 deletions src/RoslynTestKit/TestDiagnosticProvider.cs
Original file line number Diff line number Diff line change
@@ -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<Diagnostic> _diagnostics;

public TestDiagnosticProvider(ImmutableArray<Diagnostic> diagnostics)
{
_diagnostics = diagnostics;
}

public override async Task<IEnumerable<Diagnostic>> GetDocumentDiagnosticsAsync(
Document document, ISet<string> 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<IEnumerable<Diagnostic>> GetProjectDiagnosticsAsync(
Project project, ISet<string> diagnosticIdsWithFixes, CancellationToken cancellationToken)
{
return Task.FromResult(Enumerable.Empty<Diagnostic>());
}

public override Task<IEnumerable<Diagnostic>> GetAllDiagnosticsAsync(
Project project, ISet<string> diagnosticIdsWithFixes, CancellationToken cancellationToken)
{
return Task.FromResult<IEnumerable<Diagnostic>>(
_diagnostics.Where(d => diagnosticIdsWithFixes.Contains(d.Id)));
}
}
}
Loading