Skip to content
Open
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
249 changes: 249 additions & 0 deletions test/NetEvolve.Analyzer.Tests.Unit/CodeActionMetadataTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<OneTypePerFileAnalyzer, OneTypePerFileCodeFixProvider>
.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<OneTypePerFileAnalyzer, OneTypePerFileCodeFixProvider>
.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<NamespaceMatchesFolderAnalyzer, NamespaceMatchesFolderCodeFixProvider>
.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<SingleNamespacePerFileAnalyzer, SingleNamespacePerFileCodeFixProvider>
.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<UseIsNullAnalyzer, UseIsNullCodeFixProvider>
.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<UseIsNotNullAnalyzer, UseIsNotNullCodeFixProvider>
.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<UseIsNotNullOverIsObjectAnalyzer, UseIsNotNullOverIsObjectCodeFixProvider>
.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<UseLangwordAnalyzer, UseLangwordCodeFixProvider>
.DescribeAsync(
"public sealed class Sample\n{\n /// <summary>Returns <c>true</c> on success.</summary>\n public bool Succeeded() => true;\n}\n",
"NE0007"
)
.ConfigureAwait(false)
)
.IsEqualTo("""Use <see langword="true"/> instead [NE0007.UseLangword]""");

[Test]
public async Task NE0008_UseCref() =>
await Assert
.That(
await CodeActionProbe<NativeTypeCrefAnalyzer, NativeTypeCrefCodeFixProvider>
.DescribeAsync(
"public sealed class Sample\n{\n /// <summary>Returns a <c>string</c>.</summary>\n public string Value() => \"\";\n}\n",
"NE0008"
)
.ConfigureAwait(false)
)
.IsEqualTo("""Use <see cref="string"/> instead [NE0008.UseCref]""");

[Test]
public async Task NE0009_BothCheckForms() =>
await Assert
.That(
await CodeActionProbe<RequireCancellationCheckAnalyzer, RequireCancellationCheckCodeFixProvider>
.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<AvoidRegionDirectivesAnalyzer, AvoidRegionDirectivesCodeFixProvider>
.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<RequireNumericLiteralSuffixAnalyzer, RequireNumericLiteralSuffixCodeFixProvider>
.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<RequireNumericLiteralSuffixAnalyzer, RequireNumericLiteralSuffixCodeFixProvider>
.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<AvoidInvisibleCharactersAnalyzer, AvoidInvisibleCharactersCodeFixProvider>
.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<AvoidInvisibleCharactersAnalyzer, AvoidInvisibleCharactersCodeFixProvider>
.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<long>();\n list.Clear();\n list.Add(1L);\n }\n}\n",
"NE0015"
)
.ConfigureAwait(false)
)
.IsEqualTo("Remove redundant 'Clear()' call [NE0015.RemoveRedundantClear]");
}
19 changes: 19 additions & 0 deletions test/NetEvolve.Analyzer.Tests.Unit/Maintainability/FixAllRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@ public static async Task<IReadOnlyDictionary<string, string>> FixAllAsync(
return result;
}

/// <summary>Returns the title of the fix-all action offered for <paramref name="sources"/>, or <see langword="null"/> when none is.</summary>
public static async Task<string?> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
85 changes: 85 additions & 0 deletions test/NetEvolve.Analyzer.Tests.Unit/Verifiers/CodeActionProbe.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Runs <typeparamref name="TAnalyzer"/> over a source file, hands the first diagnostic with the requested
/// identifier to <typeparamref name="TCodeFix"/> and returns the registered code actions, so tests can assert
/// what the IDE lightbulb shows (<see cref="CodeAction.Title"/>) and how Fix All groups the action
/// (<see cref="CodeAction.EquivalenceKey"/>). The regular verifiers only compare the fixed source.
/// </summary>
/// <typeparam name="TAnalyzer">The analyzer producing the diagnostic.</typeparam>
/// <typeparam name="TCodeFix">The code fix under test.</typeparam>
internal static class CodeActionProbe<TAnalyzer, TCodeFix>
where TAnalyzer : DiagnosticAnalyzer, new()
where TCodeFix : CodeFixProvider, new()
{
/// <summary>
/// Describes every action registered for the first <paramref name="diagnosticId"/> diagnostic as
/// <c>Title [EquivalenceKey]</c> (actions joined with <c>" | "</c>, key <c>-</c> when absent).
/// </summary>
public static async Task<string> 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<DiagnosticAnalyzer>(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<CodeAction>();
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 ?? "-"}]"));
}
}
Loading