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 ?? "-"}]")); + } +}