196 lines
7.1 KiB
C#
196 lines
7.1 KiB
C#
using System.Collections.Immutable;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.Diagnostics;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Analyzers;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Analyzers.Tests;
|
|
|
|
/// <summary>
|
|
/// Compile-a-snippet-and-run-the-analyzer tests. Avoids
|
|
/// Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit because it pins to xunit v2 +
|
|
/// this project uses xunit.v3 like the rest of the solution. Hand-rolled harness is 15
|
|
/// lines + makes the assertion surface obvious at the test-author level.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class UnwrappedCapabilityCallAnalyzerTests
|
|
{
|
|
/// <summary>Minimal stubs for the guarded interfaces + the two wrapper types. Keeps the
|
|
/// analyzer tests independent of the real OtOpcUa project references so a drift in those
|
|
/// signatures doesn't secretly mute the analyzer check.</summary>
|
|
private const string StubSources = """
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions {
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Collections.Generic;
|
|
public interface IReadable {
|
|
ValueTask<IReadOnlyList<object>> ReadAsync(IReadOnlyList<string> tags, CancellationToken ct);
|
|
}
|
|
public interface IWritable {
|
|
ValueTask WriteAsync(IReadOnlyList<object> ops, CancellationToken ct);
|
|
}
|
|
public interface ITagDiscovery {
|
|
Task DiscoverAsync(CancellationToken ct);
|
|
}
|
|
public enum DriverCapability { Read, Write, Discover }
|
|
}
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience {
|
|
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
public sealed class CapabilityInvoker {
|
|
public ValueTask<T> ExecuteAsync<T>(DriverCapability c, string host, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct) => throw null!;
|
|
public ValueTask ExecuteAsync(DriverCapability c, string host, Func<CancellationToken, ValueTask> call, CancellationToken ct) => throw null!;
|
|
public ValueTask<T> ExecuteWriteAsync<T>(string host, bool isIdempotent, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct) => throw null!;
|
|
}
|
|
}
|
|
""";
|
|
|
|
[Fact]
|
|
public async Task Direct_ReadAsync_Call_InServerNamespace_TripsDiagnostic()
|
|
{
|
|
const string userSrc = """
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server {
|
|
public sealed class BadCaller {
|
|
public async Task DoIt(IReadable driver) {
|
|
var _ = await driver.ReadAsync(new List<string>(), CancellationToken.None);
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
var diags = await Compile(userSrc);
|
|
diags.Length.ShouldBe(1);
|
|
diags[0].Id.ShouldBe(UnwrappedCapabilityCallAnalyzer.DiagnosticId);
|
|
diags[0].GetMessage().ShouldContain("ReadAsync");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Wrapped_ReadAsync_InsideCapabilityInvokerLambda_PassesCleanly()
|
|
{
|
|
const string userSrc = """
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server {
|
|
public sealed class GoodCaller {
|
|
public async Task DoIt(IReadable driver, CapabilityInvoker invoker) {
|
|
var _ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
|
|
async ct => await driver.ReadAsync(new List<string>(), ct), CancellationToken.None);
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
var diags = await Compile(userSrc);
|
|
diags.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DirectWrite_WithoutWrapper_TripsDiagnostic()
|
|
{
|
|
const string userSrc = """
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server {
|
|
public sealed class BadWrite {
|
|
public async Task DoIt(IWritable driver) {
|
|
await driver.WriteAsync(new List<object>(), CancellationToken.None);
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
var diags = await Compile(userSrc);
|
|
diags.Length.ShouldBe(1);
|
|
diags[0].GetMessage().ShouldContain("WriteAsync");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Discovery_Call_WithoutWrapper_TripsDiagnostic()
|
|
{
|
|
const string userSrc = """
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server {
|
|
public sealed class BadDiscover {
|
|
public async Task DoIt(ITagDiscovery driver) {
|
|
await driver.DiscoverAsync(CancellationToken.None);
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
var diags = await Compile(userSrc);
|
|
diags.Length.ShouldBe(1);
|
|
diags[0].GetMessage().ShouldContain("DiscoverAsync");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_OutsideOfLambda_ButInsideInvokerCall_StillTripsDiagnostic()
|
|
{
|
|
// Precompute the read *outside* the lambda, then pass the awaited result — that does NOT
|
|
// actually wrap the ReadAsync call in the resilience pipeline, so the analyzer must
|
|
// still flag it (regression guard: a naive "any mention of ExecuteAsync nearby" rule
|
|
// would silently let this pattern through).
|
|
const string userSrc = """
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server {
|
|
public sealed class SneakyCaller {
|
|
public async Task DoIt(IReadable driver, CapabilityInvoker invoker) {
|
|
var result = await driver.ReadAsync(new List<string>(), CancellationToken.None); // not inside any lambda
|
|
await invoker.ExecuteAsync(DriverCapability.Read, "h1",
|
|
async ct => { await Task.Yield(); }, CancellationToken.None);
|
|
_ = result;
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
var diags = await Compile(userSrc);
|
|
diags.Length.ShouldBe(1);
|
|
}
|
|
|
|
private static async Task<ImmutableArray<Diagnostic>> Compile(string userSource)
|
|
{
|
|
var syntaxTrees = new[]
|
|
{
|
|
CSharpSyntaxTree.ParseText(StubSources),
|
|
CSharpSyntaxTree.ParseText(userSource),
|
|
};
|
|
var references = AppDomain.CurrentDomain.GetAssemblies()
|
|
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
|
|
.Select(a => MetadataReference.CreateFromFile(a.Location))
|
|
.Cast<MetadataReference>()
|
|
.ToList();
|
|
|
|
var compilation = CSharpCompilation.Create(
|
|
assemblyName: "AnalyzerTestAssembly",
|
|
syntaxTrees: syntaxTrees,
|
|
references: references,
|
|
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
|
|
|
var withAnalyzers = compilation.WithAnalyzers(
|
|
ImmutableArray.Create<DiagnosticAnalyzer>(new UnwrappedCapabilityCallAnalyzer()));
|
|
|
|
var allDiags = await withAnalyzers.GetAnalyzerDiagnosticsAsync();
|
|
return allDiags.Where(d => d.Id == UnwrappedCapabilityCallAnalyzer.DiagnosticId).ToImmutableArray();
|
|
}
|
|
}
|