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; /// /// 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. /// [Trait("Category", "Unit")] public sealed class UnwrappedCapabilityCallAnalyzerTests { /// 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. 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> ReadAsync(IReadOnlyList tags, CancellationToken ct); } public interface IWritable { ValueTask WriteAsync(IReadOnlyList 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 ExecuteAsync(DriverCapability c, string host, Func> call, CancellationToken ct) => throw null!; public ValueTask ExecuteAsync(DriverCapability c, string host, Func call, CancellationToken ct) => throw null!; public ValueTask ExecuteWriteAsync(string host, bool isIdempotent, Func> 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(), 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(), 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(), 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(), 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> 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() .ToList(); var compilation = CSharpCompilation.Create( assemblyName: "AnalyzerTestAssembly", syntaxTrees: syntaxTrees, references: references, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var withAnalyzers = compilation.WithAnalyzers( ImmutableArray.Create(new UnwrappedCapabilityCallAnalyzer())); var allDiags = await withAnalyzers.GetAnalyzerDiagnosticsAsync(); return allDiags.Where(d => d.Id == UnwrappedCapabilityCallAnalyzer.DiagnosticId).ToImmutableArray(); } }