From fd9f92dcfeefc74a68261b87f2a1a7519c9d5396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 29 Oct 2026 05:10:18 +0100 Subject: [PATCH 1/2] test: assert code action titles and equivalence keys of all code fixes The code-fix verifiers only compare the fixed source, never which action produced it. Stryker therefore reports the string mutants on every CodeAction title and equivalenceKey as survived (about 34 in 14 providers, plus the Fix All title of SequentialFixAllProvider). Add CodeActionProbe, which runs an analyzer plus code fix over a source file and returns the registered actions, and CodeActionMetadataTests, which pins Title and EquivalenceKey per provider, including both actions of NE0001, NE0009 and NE0014, the keyless ambiguous NE0012 actions and the pre-C# 9 title of NE0005. FixAllRunner gains a title accessor so the sequential Fix All action title is asserted as well. --- .../CodeActionMetadataTests.cs | 249 ++++++++++++++++++ .../Maintainability/FixAllRunner.cs | 19 ++ .../OneTypePerFileFixAllTests.cs | 13 + .../Verifiers/CodeActionProbe.cs | 85 ++++++ 4 files changed, 366 insertions(+) create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs create mode 100644 test/NetEvolve.Analyzer.Tests.Unit/Verifiers/CodeActionProbe.cs diff --git a/test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs new file mode 100644 index 0000000..c17ad30 --- /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​Class { }", "NE0014") + .ConfigureAwait(false) + ) + .IsEqualTo("Rename to 'MyClass' [NE0014.RemoveInvisibleCharacters]"); + + [Test] + public async Task NE0014_RemoveTextSpan() => + await Assert + .That( + await CodeActionProbe + .DescribeAsync("namespace 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 ?? "-"}]")); + } +} From b691b9908750379baad5645ad8df53b8bdf5f64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 29 Oct 2026 17:12:49 +0100 Subject: [PATCH 2/2] test: escape invisible characters in code action metadata tests Resolves S2479 in CodeActionMetadataTests by using explicit escape sequences instead of literal zero-width and BOM characters. --- test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs b/test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs index c17ad30..2c5524d 100644 --- a/test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs +++ b/test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs @@ -216,7 +216,7 @@ public async Task NE0014_RenameIdentifier() => await Assert .That( await CodeActionProbe - .DescribeAsync("public sealed class My​Class { }", "NE0014") + .DescribeAsync("public sealed class My\u200BClass { }", "NE0014") .ConfigureAwait(false) ) .IsEqualTo("Rename to 'MyClass' [NE0014.RemoveInvisibleCharacters]"); @@ -226,7 +226,7 @@ public async Task NE0014_RemoveTextSpan() => await Assert .That( await CodeActionProbe - .DescribeAsync("namespace My.Project;\n\npublic sealed class Sample { }\n", "NE0014") + .DescribeAsync("\uFEFFnamespace My.Project;\n\npublic sealed class Sample { }\n", "NE0014") .ConfigureAwait(false) ) .IsEqualTo("Remove non-representable character [NE0014.RemoveInvisibleTextSpan]");