Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,6 @@ FodyWeavers.xsd

# Claude local settings
.claude/*.local.json

# Conducktor local files
.conducktor/
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using XrmPluginCore.SourceGenerator.Tests.Helpers;

namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests;
Expand Down Expand Up @@ -82,6 +83,75 @@ protected static async Task<string> ApplyCodeFixAsync(
return newText.ToString();
}

/// <summary>
/// Applies the code fix provider's FixAll provider across every matching diagnostic in the
/// document (Document scope), exercising the custom <c>FixAllProvider</c> rather than a single
/// per-diagnostic fix.
/// </summary>
protected static async Task<string> ApplyFixAllAsync(
string source,
DiagnosticAnalyzer analyzer,
CodeFixProvider codeFixProvider,
string equivalenceKey,
params string[] diagnosticIds)
{
var document = CreateDocument(source);

// Compute diagnostics against the document's own compilation so their locations reference the
// document's syntax tree (the FixAll provider maps locations back to documents).
var compilation = await document.Project.GetCompilationAsync();
var compilationWithAnalyzers = compilation!.WithAnalyzers([analyzer]);
var analyzerDiagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
var diagnostics = analyzerDiagnostics.Where(d => diagnosticIds.Contains(d.Id)).ToImmutableArray();

if (diagnostics.IsEmpty)
{
return source;
}

var fixAllProvider = codeFixProvider.GetFixAllProvider()!;
var fixAllContext = new FixAllContext(
document,
codeFixProvider,
FixAllScope.Document,
equivalenceKey,
diagnosticIds,
new FixAllDiagnosticProvider(diagnostics),
CancellationToken.None);

var codeAction = await fixAllProvider.GetFixAsync(fixAllContext);
if (codeAction == null)
{
return source;
}

var operations = await codeAction.GetOperationsAsync(CancellationToken.None);
var changedSolution = operations.OfType<ApplyChangesOperation>().Single().ChangedSolution;
var changedDocument = changedSolution.GetDocument(document.Id);
var newText = await changedDocument!.GetTextAsync();

return newText.ToString();
}

private sealed class FixAllDiagnosticProvider : FixAllContext.DiagnosticProvider
{
private readonly ImmutableArray<Diagnostic> _diagnostics;

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

public override Task<IEnumerable<Diagnostic>> GetDocumentDiagnosticsAsync(Document document, CancellationToken cancellationToken)
=> Task.FromResult<IEnumerable<Diagnostic>>(_diagnostics);

public override Task<IEnumerable<Diagnostic>> GetAllDiagnosticsAsync(Project project, CancellationToken cancellationToken)
=> Task.FromResult<IEnumerable<Diagnostic>>(_diagnostics);

public override Task<IEnumerable<Diagnostic>> GetProjectDiagnosticsAsync(Project project, CancellationToken cancellationToken)
=> Task.FromResult<IEnumerable<Diagnostic>>(Enumerable.Empty<Diagnostic>());
}

protected static async Task<List<CodeAction>> GetCodeActionsAsync(
string source,
DiagnosticAnalyzer analyzer,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using FluentAssertions;
using XrmPluginCore.SourceGenerator.Analyzers;
using XrmPluginCore.SourceGenerator.CodeFixes;
using XrmPluginCore.SourceGenerator.Tests.Helpers;
using Xunit;

namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests;
Expand Down Expand Up @@ -55,11 +56,11 @@ public class TestService : ITestService
// Act
var fixedSource = await ApplyCodeFixAsync(source);

// Assert - Method created with PreImage parameter
fixedSource.Should().Contain("void HandleUpdate(PreImage preImage)");
// Assert - Method created with alias-qualified PreImage parameter
fixedSource.Should().Contain("void HandleUpdate(AccountUpdatePostOperation.PreImage preImage)");

// Assert - Using directive added
fixedSource.Should().Contain("using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;");
// Assert - Aliased using directive added
fixedSource.Should().Contain("using AccountUpdatePostOperation = TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;");
}

[Fact]
Expand Down Expand Up @@ -107,11 +108,11 @@ public class TestService : ITestService
// Act
var fixedSource = await ApplyCodeFixAsync(source);

// Assert - Method created with both image parameters
fixedSource.Should().Contain("void HandleUpdate(PreImage preImage, PostImage postImage)");
// Assert - Method created with both alias-qualified image parameters
fixedSource.Should().Contain("void HandleUpdate(AccountUpdatePostOperation.PreImage preImage, AccountUpdatePostOperation.PostImage postImage)");

// Assert - Using directive added
fixedSource.Should().Contain("using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;");
// Assert - Aliased using directive added
fixedSource.Should().Contain("using AccountUpdatePostOperation = TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;");
}

[Fact]
Expand Down Expand Up @@ -165,9 +166,11 @@ public class TestService : ITestService
}

[Fact]
public async Task Should_Avoid_Ambiguous_Usings()
public async Task Should_Use_Aliased_Usings_For_Multiple_Registrations()
{
// Arrange - Using directive already exists for Update registration, method missing for Delete
// Arrange - A plain using already exists for the Update registration; the Delete handler is
// missing from the interface. Creating Delete must requalify the existing Update reference to
// its alias (resolved via the semantic model) and add the Delete using in aliased form.
const string source = """
using System;
using System.ComponentModel;
Expand Down Expand Up @@ -211,22 +214,191 @@ public class TestService : ITestService
public void HandleUpdate(PreImage preImage) { }
}
}

namespace TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation
{
public sealed class PreImage { }
public sealed class PostImage { }
}

namespace TestNamespace.PluginRegistrations.TestPlugin.AccountDeletePostOperation
{
public sealed class PreImage { }
public sealed class PostImage { }
}
""";

// Act
var fixedSource = await ApplyCodeFixAsync(source);

// Assert - New method created with qualified type
// Assert - New method created with its own alias-qualified type
fixedSource.Should().Contain("void HandleDelete(AccountDeletePostOperation.PreImage preImage)");

// Assert - Existing PreImage references qualified with alias
// Assert - Existing bare PreImage references requalified with the Update alias
CountOccurrences(fixedSource, "AccountUpdatePostOperation.PreImage preImage").Should().BeGreaterOrEqualTo(1);

// Assert - Aliased usings
// Assert - Each namespace aliased exactly once
CountOccurrences(fixedSource, "using AccountUpdatePostOperation = TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;")
.Should().Be(1, "the existing using should be converted to aliased form");
.Should().Be(1, "the existing plain using should be converted to aliased form");
CountOccurrences(fixedSource, "using AccountDeletePostOperation = TestNamespace.PluginRegistrations.TestPlugin.AccountDeletePostOperation;")
.Should().Be(1, "the new using should be added in aliased form");

AssertNoAmbiguousReferences(fixedSource);
}

[Fact]
public async Task FixAll_Should_Create_Multiple_Methods_On_Same_Interface()
{
// Arrange - Two same-service registrations, both missing their handler methods.
const string source = """
using System;
using System.ComponentModel;
using Microsoft.Xrm.Sdk;
using XrmPluginCore;
using XrmPluginCore.Enums;
using Microsoft.Extensions.DependencyInjection;
using XrmPluginCore.Tests.Context.BusinessDomain;

namespace TestNamespace
{
public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
"HandleUpdate")
.AddFilteredAttributes(x => x.Name)
.WithPreImage(x => x.Name, x => x.Revenue);

RegisterStep<Account, ITestService>(EventOperation.Delete, ExecutionStage.PostOperation,
"HandleDelete")
.AddFilteredAttributes(x => x.Name)
.WithPreImage(x => x.Name, x => x.Revenue);
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
{
return services.AddScoped<ITestService, TestService>();
}
}

public interface ITestService
{
}

public class TestService : ITestService
{
}
}

namespace TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation
{
public sealed class PreImage { }
public sealed class PostImage { }
}

namespace TestNamespace.PluginRegistrations.TestPlugin.AccountDeletePostOperation
{
public sealed class PreImage { }
public sealed class PostImage { }
}
""";

// Act - Apply the consolidating FixAll across all diagnostics in one pass
var fixedSource = await ApplyFixAllAsync(source);

// Assert - Both methods created with their own alias-qualified parameter
fixedSource.Should().Contain("void HandleUpdate(AccountUpdatePostOperation.PreImage preImage)");
fixedSource.Should().Contain("void HandleDelete(AccountDeletePostOperation.PreImage preImage)");

// Assert - One aliased using per distinct namespace (no duplicates)
CountOccurrences(fixedSource, "using AccountUpdatePostOperation = TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;").Should().Be(1);
CountOccurrences(fixedSource, "using AccountDeletePostOperation = TestNamespace.PluginRegistrations.TestPlugin.AccountDeletePostOperation;").Should().Be(1);

AssertNoAmbiguousReferences(fixedSource);
}

[Fact]
public async Task Should_Add_Method_To_Correct_Interface_When_Names_Collide()
{
// Arrange - A decoy interface with the SAME simple name lives in another namespace and is
// declared FIRST in the document. The registered service is TestNamespace.ITestService. The
// fix must add the method to the registered interface, not the first one matching by name.
const string source = """
using System;
using System.ComponentModel;
using Microsoft.Xrm.Sdk;
using XrmPluginCore;
using XrmPluginCore.Enums;
using Microsoft.Extensions.DependencyInjection;
using XrmPluginCore.Tests.Context.BusinessDomain;

namespace DecoyNamespace
{
public interface ITestService
{
void SomethingElse();
}
}

namespace TestNamespace
{
public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
"HandleUpdate")
.AddFilteredAttributes(x => x.Name)
.WithPreImage(x => x.Name, x => x.Revenue);
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
{
return services.AddScoped<ITestService, TestService>();
}
}

public interface ITestService
{
}

public class TestService : ITestService
{
}
}

namespace TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation
{
public sealed class PreImage { }
public sealed class PostImage { }
}
""";

// Act
var fixedSource = await ApplyCodeFixAsync(source);

// Assert - The method landed on the registered interface, not the decoy
var compilation = CompilationHelper.CreateCompilation(fixedSource);
var registered = compilation.GetTypeByMetadataName("TestNamespace.ITestService");
var decoy = compilation.GetTypeByMetadataName("DecoyNamespace.ITestService");

registered.Should().NotBeNull();
decoy.Should().NotBeNull();
registered!.GetMembers("HandleUpdate").Should().HaveCount(1, "the method must be added to the registered interface");
decoy!.GetMembers("HandleUpdate").Should().BeEmpty("the method must not be added to the same-named decoy interface");

AssertNoAmbiguousReferences(fixedSource);
}

private static void AssertNoAmbiguousReferences(string source)
{
var compilation = CompilationHelper.CreateCompilation(source);
var ambiguous = compilation.GetDiagnostics()
.Where(d => d.Id == "CS0104")
.Select(d => d.GetMessage())
.ToList();
ambiguous.Should().BeEmpty("the fixed source should not contain ambiguous references");
}

private static int CountOccurrences(string source, string search)
Expand All @@ -244,4 +416,9 @@ private static int CountOccurrences(string source, string search)
private static Task<string> ApplyCodeFixAsync(string source)
=> ApplyCodeFixAsync(source, new HandlerMethodNotFoundAnalyzer(), new CreateHandlerMethodCodeFixProvider(),
DiagnosticDescriptors.HandlerMethodNotFound.Id);

private static Task<string> ApplyFixAllAsync(string source)
=> ApplyFixAllAsync(source, new HandlerMethodNotFoundAnalyzer(), new CreateHandlerMethodCodeFixProvider(),
nameof(CreateHandlerMethodCodeFixProvider),
DiagnosticDescriptors.HandlerMethodNotFound.Id);
}
Loading
Loading