diff --git a/test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs
new file mode 100644
index 0000000..2c5524d
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs
@@ -0,0 +1,249 @@
+namespace NetEvolve.Analyzer.Tests.Unit;
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.CSharp;
+using NetEvolve.Analyzer.Documentation;
+using NetEvolve.Analyzer.Maintainability;
+using NetEvolve.Analyzer.Naming;
+using NetEvolve.Analyzer.Style;
+using NetEvolve.Analyzer.Tests.Unit.Verifiers;
+using NetEvolve.Analyzer.Usage;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+using TUnit.Core;
+
+///
+/// Pins the user-visible title and the Fix All equivalence key of every code action the code fixes register.
+/// The code-fix verifiers only compare the fixed source, so a wrong or empty title (IDE lightbulb text) or
+/// key (Fix All grouping) would otherwise go unnoticed.
+///
+public sealed class CodeActionMetadataTests
+{
+ private const string GlobalConfig =
+ "is_global = true\nbuild_property.RootNamespace = Geometry\nbuild_property.ProjectDir = /proj\n";
+
+ [Test]
+ public async Task NE0001_SingleType_RenameFile() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync("namespace Geometry;\n\npublic sealed class Circle { }\n", "NE0001", "Shapes.cs")
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Rename file to 'Circle.cs' [NE0001.RenameFile]");
+
+ [Test]
+ public async Task NE0001_MultipleTypes_MoveType() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "namespace Geometry;\n\npublic sealed class Circle { }\n\npublic sealed class Square { }\n",
+ "NE0001",
+ "Circle.cs"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Move type to 'Square.cs' [NE0001.MoveType]");
+
+ [Test]
+ public async Task NE0002_ChangeNamespace() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "namespace Wrong;\n\npublic sealed class Circle { }\n",
+ "NE0002",
+ "/proj/Shapes/Circle.cs",
+ globalConfig: GlobalConfig
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Change namespace to 'Geometry.Shapes' [NE0002.ChangeNamespace]");
+
+ [Test]
+ public async Task NE0003_Flatten() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "namespace Outer\n{\n namespace Inner\n {\n public sealed class Circle { }\n }\n}\n",
+ "NE0003",
+ "Circle.cs"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Flatten to a single namespace [NE0003.Flatten]");
+
+ [Test]
+ public async Task NE0004_UseIsNull() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "public sealed class Sample { public bool Check(object value) => value == null; }",
+ "NE0004"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Use 'is null' pattern [NE0004.UseIsNull]");
+
+ [Test]
+ [Arguments(LanguageVersion.Latest, "Use 'is not null' [NE0005.UseIsNotNull]")]
+ [Arguments(LanguageVersion.CSharp8, "Use '!(... is null)' [NE0005.UseIsNotNull]")]
+ public async Task NE0005_UseIsNotNull(LanguageVersion languageVersion, string expected) =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "public sealed class Sample { public bool Check(string value) => value != null; }",
+ "NE0005",
+ languageVersion: languageVersion
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo(expected);
+
+ [Test]
+ public async Task NE0006_UseIsNotNullOverIsObject() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "public sealed class Sample { public bool Check(string value) => value is object; }",
+ "NE0006"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Use 'is not null' [NE0006.UseIsNotNull]");
+
+ [Test]
+ public async Task NE0007_UseLangword() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "public sealed class Sample\n{\n /// Returns true on success.\n public bool Succeeded() => true;\n}\n",
+ "NE0007"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("""Use instead [NE0007.UseLangword]""");
+
+ [Test]
+ public async Task NE0008_UseCref() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "public sealed class Sample\n{\n /// Returns a string.\n public string Value() => \"\";\n}\n",
+ "NE0008"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("""Use instead [NE0008.UseCref]""");
+
+ [Test]
+ public async Task NE0009_BothCheckForms() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "using System.Threading;\n\npublic sealed class Sample\n{\n public void Run(CancellationToken cancellationToken)\n {\n DoWork();\n }\n\n private static void DoWork() { }\n}\n",
+ "NE0009"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo(
+ "Add ThrowIfCancellationRequested() check [NE0009.ThrowIfCancellationRequested]"
+ + " | Add IsCancellationRequested check [NE0009.IsCancellationRequested]"
+ );
+
+ [Test]
+ public async Task NE0010_AddCancellationTokenParameter() =>
+ await Assert
+ .That(
+ await CodeActionProbe<
+ RequireCancellationTokenParameterAnalyzer,
+ RequireCancellationTokenParameterCodeFixProvider
+ >
+ .DescribeAsync(
+ "using System.Threading;\nusing System.Threading.Tasks;\n\npublic sealed class Sample\n{\n public Task Run() => HelperAsync();\n\n private static Task HelperAsync(CancellationToken token = default) => Task.CompletedTask;\n}\n",
+ "NE0010"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Add CancellationToken parameter [NE0010.RequireCancellationTokenParameter]");
+
+ [Test]
+ public async Task NE0011_RemoveRegion() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "public sealed class Sample\n{\n public void DoWork()\n {\n #region Logic\n var x = 1;\n #endregion\n }\n}\n",
+ "NE0011"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Remove '#region' directive [NE0011.RemoveRegion]");
+
+ [Test]
+ public async Task NE0012_SingleSuffix() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync("public sealed class Sample { public long Value = 0; }", "NE0012")
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Use the 'L' suffix [NE0012.AddSuffix]");
+
+ [Test]
+ public async Task NE0012_AmbiguousOverloads_OneKeylessActionPerSuffix() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync(
+ "public sealed class Sample\n{\n public void Accept(long number) { }\n\n public void Accept(double number) { }\n\n public void Call() => Accept(0);\n}\n",
+ "NE0012"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Use the 'L' suffix [-] | Use the 'D' suffix [-]");
+
+ [Test]
+ public async Task NE0014_RenameIdentifier() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync("public sealed class My\u200BClass { }", "NE0014")
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Rename to 'MyClass' [NE0014.RemoveInvisibleCharacters]");
+
+ [Test]
+ public async Task NE0014_RemoveTextSpan() =>
+ await Assert
+ .That(
+ await CodeActionProbe
+ .DescribeAsync("\uFEFFnamespace My.Project;\n\npublic sealed class Sample { }\n", "NE0014")
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Remove non-representable character [NE0014.RemoveInvisibleTextSpan]");
+
+ [Test]
+ public async Task NE0015_RemoveRedundantClear() =>
+ await Assert
+ .That(
+ await CodeActionProbe<
+ AvoidRedundantCollectionClearAnalyzer,
+ AvoidRedundantCollectionClearCodeFixProvider
+ >
+ .DescribeAsync(
+ "using System.Collections.Generic;\n\npublic sealed class Sample\n{\n public void Run()\n {\n var list = new List();\n list.Clear();\n list.Add(1L);\n }\n}\n",
+ "NE0015"
+ )
+ .ConfigureAwait(false)
+ )
+ .IsEqualTo("Remove redundant 'Clear()' call [NE0015.RemoveRedundantClear]");
+}
diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs
index 3c293c3..5d9458b 100644
--- a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs
+++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs
@@ -49,6 +49,25 @@ public static async Task> FixAllAsync(
return result;
}
+ /// Returns the title of the fix-all action offered for , or when none is.
+ public static async Task FixAllTitleAsync(
+ (string Name, string Content)[] sources,
+ FixAllScope scope,
+ CancellationToken cancellationToken = default
+ )
+ {
+ using var workspace = new AdhocWorkspace();
+ var projectId = ProjectId.CreateNewId();
+ var project = BuildSolution(workspace, projectId, sources).GetProject(projectId)!;
+ var context = await CreateContextAsync(project, scope, cancellationToken).ConfigureAwait(false);
+
+ var action = await new OneTypePerFileCodeFixProvider()
+ .GetFixAllProvider()!
+ .GetFixAsync(context)
+ .ConfigureAwait(false);
+ return action?.Title;
+ }
+
private static Solution BuildSolution(
AdhocWorkspace workspace,
ProjectId projectId,
diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs
index 21b84b3..cc0c395 100644
--- a/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs
+++ b/test/NetEvolve.Analyzer.Tests.Unit/Maintainability/OneTypePerFileFixAllTests.cs
@@ -40,6 +40,19 @@ public sealed class Triangle { }
await Assert.That(result.ContainsKey("Triangle.cs")).IsTrue();
}
+ [Test]
+ public async Task FixAllAction_Title_NamesTheDiagnostic()
+ {
+ var title = await FixAllRunner
+ .FixAllTitleAsync(
+ [("Shapes.cs", "namespace Geometry;\n\npublic sealed class Circle { }\n")],
+ FixAllScope.Project
+ )
+ .ConfigureAwait(false);
+
+ await Assert.That(title).IsEqualTo("Fix all 'NE0001' occurrences");
+ }
+
[Test]
public async Task Project_MultipleViolatingFiles_AllResolved()
{
diff --git a/test/NetEvolve.Analyzer.Tests.Unit/Verifiers/CodeActionProbe.cs b/test/NetEvolve.Analyzer.Tests.Unit/Verifiers/CodeActionProbe.cs
new file mode 100644
index 0000000..41252d0
--- /dev/null
+++ b/test/NetEvolve.Analyzer.Tests.Unit/Verifiers/CodeActionProbe.cs
@@ -0,0 +1,85 @@
+namespace NetEvolve.Analyzer.Tests.Unit.Verifiers;
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+
+///
+/// Runs over a source file, hands the first diagnostic with the requested
+/// identifier to and returns the registered code actions, so tests can assert
+/// what the IDE lightbulb shows () and how Fix All groups the action
+/// (). The regular verifiers only compare the fixed source.
+///
+/// The analyzer producing the diagnostic.
+/// The code fix under test.
+internal static class CodeActionProbe
+ where TAnalyzer : DiagnosticAnalyzer, new()
+ where TCodeFix : CodeFixProvider, new()
+{
+ ///
+ /// Describes every action registered for the first diagnostic as
+ /// Title [EquivalenceKey] (actions joined with " | ", key - when absent).
+ ///
+ public static async Task DescribeAsync(
+ string source,
+ string diagnosticId,
+ string filePath = "Test.cs",
+ LanguageVersion languageVersion = LanguageVersion.Latest,
+ string? globalConfig = null
+ )
+ {
+ using var workspace = new AdhocWorkspace();
+ var projectId = ProjectId.CreateNewId();
+ var documentId = DocumentId.CreateNewId(projectId);
+ var projectInfo = ProjectInfo
+ .Create(projectId, VersionStamp.Default, "Sample", "Sample", LanguageNames.CSharp)
+ .WithMetadataReferences(FrameworkReferences.All)
+ .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
+ .WithParseOptions(new CSharpParseOptions(languageVersion, DocumentationMode.Diagnose));
+
+ var solution = workspace
+ .CurrentSolution.AddProject(projectInfo)
+ .AddDocument(documentId, filePath, SourceText.From(source), filePath: filePath);
+ if (globalConfig is not null)
+ {
+ solution = solution.AddAnalyzerConfigDocument(
+ DocumentId.CreateNewId(projectId),
+ ".globalconfig",
+ SourceText.From(globalConfig),
+ filePath: "/.globalconfig"
+ );
+ }
+
+ var project = solution.GetProject(projectId)!;
+ var compilation = (await project.GetCompilationAsync().ConfigureAwait(false))!;
+
+ // S8949/CA2016: the cancellation-token WithAnalyzers overload is obsolete.
+#pragma warning disable S8949, CA2016
+ var diagnostics = await compilation
+ .WithAnalyzers(ImmutableArray.Create(new TAnalyzer()), project.AnalyzerOptions)
+ .GetAnalyzerDiagnosticsAsync()
+ .ConfigureAwait(false);
+#pragma warning restore S8949, CA2016
+
+ var diagnostic = diagnostics.First(d => string.Equals(d.Id, diagnosticId, StringComparison.Ordinal));
+ var actions = new List();
+ var context = new CodeFixContext(
+ solution.GetDocument(documentId)!,
+ diagnostic,
+ (action, _) => actions.Add(action),
+ CancellationToken.None
+ );
+ await new TCodeFix().RegisterCodeFixesAsync(context).ConfigureAwait(false);
+
+ return string.Join(" | ", actions.Select(a => $"{a.Title} [{a.EquivalenceKey ?? "-"}]"));
+ }
+}