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; 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 interface ISubscriptionHandle { string DiagnosticId { get; } } public interface ISubscribable { Task SubscribeAsync(IReadOnlyList refs, TimeSpan interval, CancellationToken ct); Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken ct); } public interface IAlarmSubscriptionHandle { string DiagnosticId { get; } } public interface IAlarmSource { Task SubscribeAlarmsAsync(IReadOnlyList sourceNodeIds, CancellationToken ct); Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken ct); Task AcknowledgeAsync(IReadOnlyList acks, CancellationToken ct); } public class HostConnectivityStatus { public string HostName { get; init; } = ""; } public interface IHostConnectivityProbe { IReadOnlyList GetHostStatuses(); } public class HistoryReadResult { } public interface IHistoryProvider { Task ReadRawAsync(string fullRef, DateTime start, DateTime end, uint max, CancellationToken ct); Task ReadProcessedAsync(string fullRef, DateTime start, DateTime end, TimeSpan interval, CancellationToken ct); Task ReadAtTimeAsync(string fullRef, IReadOnlyList timestamps, CancellationToken ct) => throw new NotSupportedException(); } public enum DriverCapability { Read, Write, Discover, AlarmSubscribe, AlarmAcknowledge } } namespace ZB.MOM.WW.OtOpcUa.Core.Resilience { using System; using System.Collections.Generic; 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!; public ValueTask ExecuteWriteAsync(string host, bool isIdempotent, Func call, CancellationToken ct) => throw null!; } public sealed class AlarmSurfaceInvoker { public Task> SubscribeAsync(IReadOnlyList sourceNodeIds, CancellationToken ct) => throw null!; public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken ct) => throw null!; public Task AcknowledgeAsync(IReadOnlyList acks, 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); } // ----------------------------------------------------------------------- // ISubscribable // ----------------------------------------------------------------------- [Fact] public async Task Direct_SubscribeAsync_Call_TripsDiagnostic() { const string userSrc = """ using System; 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 BadSubscribe { public async Task DoIt(ISubscribable driver) { _ = await driver.SubscribeAsync(new List(), TimeSpan.FromSeconds(1), CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.Length.ShouldBe(1); diags[0].GetMessage().ShouldContain("SubscribeAsync"); } [Fact] public async Task Wrapped_SubscribeAsync_InsideCapabilityInvokerLambda_PassesCleanly() { const string userSrc = """ using System; 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 GoodSubscribe { public async Task DoIt(ISubscribable driver, CapabilityInvoker invoker) { _ = await invoker.ExecuteAsync(DriverCapability.AlarmSubscribe, "h1", async ct => await driver.SubscribeAsync(new List(), TimeSpan.FromSeconds(1), ct), CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.ShouldBeEmpty(); } // ----------------------------------------------------------------------- // IAlarmSource // ----------------------------------------------------------------------- [Fact] public async Task Direct_SubscribeAlarmsAsync_Call_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 BadAlarmSubscribe { public async Task DoIt(IAlarmSource source) { _ = await source.SubscribeAlarmsAsync(new List(), CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.Length.ShouldBe(1); diags[0].GetMessage().ShouldContain("SubscribeAlarmsAsync"); } [Fact] public async Task Wrapped_SubscribeAlarmsAsync_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 GoodAlarmSubscribe { public async Task DoIt(IAlarmSource source, CapabilityInvoker invoker) { _ = await invoker.ExecuteAsync(DriverCapability.AlarmSubscribe, "h1", async ct => await source.SubscribeAlarmsAsync(new List(), ct), CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.ShouldBeEmpty(); } // ----------------------------------------------------------------------- // IHistoryProvider — interface-typed receiver // ----------------------------------------------------------------------- [Fact] public async Task Direct_ReadRawAsync_OnInterface_TripsDiagnostic() { const string userSrc = """ using System; using System.Threading; using System.Threading.Tasks; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Server { public sealed class BadHistoryRead { public async Task DoIt(IHistoryProvider provider) { _ = await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.Length.ShouldBe(1); diags[0].GetMessage().ShouldContain("ReadRawAsync"); } [Fact] public async Task Direct_ReadProcessedAsync_OnInterface_TripsDiagnostic() { const string userSrc = """ using System; using System.Threading; using System.Threading.Tasks; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Server { public sealed class BadHistoryProcessed { public async Task DoIt(IHistoryProvider provider) { _ = await provider.ReadProcessedAsync("tag", DateTime.MinValue, DateTime.MaxValue, TimeSpan.FromMinutes(1), CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.Length.ShouldBe(1); diags[0].GetMessage().ShouldContain("ReadProcessedAsync"); } [Fact] public async Task Direct_ReadAtTimeAsync_OnInterface_TripsDiagnostic() { // ReadAtTimeAsync is a default interface method — the analyzer must still flag it when // called through an interface-typed receiver. const string userSrc = """ using System; 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 BadHistoryAtTime { public async Task DoIt(IHistoryProvider provider) { _ = await provider.ReadAtTimeAsync("tag", new List(), CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.Length.ShouldBe(1); diags[0].GetMessage().ShouldContain("ReadAtTimeAsync"); } [Fact] public async Task Wrapped_ReadRawAsync_InsideCapabilityInvokerLambda_PassesCleanly() { const string userSrc = """ using System; 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 GoodHistoryRead { public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) { _ = await invoker.ExecuteAsync(DriverCapability.Read, "h1", async ct => await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, ct), CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.ShouldBeEmpty(); } // ----------------------------------------------------------------------- // IHistoryProvider — concrete driver receiver (FindImplementationForInterfaceMember branch) // ----------------------------------------------------------------------- [Fact] public async Task Direct_HistoryRead_OnConcreteDriver_TripsDiagnostic() { // ConcreteHistoryDriver implements IHistoryProvider with explicitly-named methods // (same names here, but the test exercises the FindImplementationForInterfaceMember // branch because the receiver type is the concrete class, not the interface). const string userSrc = """ using System; using System.Threading; using System.Threading.Tasks; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Server { public sealed class ConcreteHistoryDriver : IHistoryProvider { public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!; public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!; } public sealed class BadConcreteHistoryRead { public async Task DoIt(ConcreteHistoryDriver driver) { _ = await driver.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.Length.ShouldBe(1); diags[0].GetMessage().ShouldContain("ReadRawAsync"); } // ----------------------------------------------------------------------- // IHostConnectivityProbe — synchronous member must NOT be flagged // ----------------------------------------------------------------------- [Fact] public async Task Synchronous_GetHostStatuses_IsNotFlagged() { // GetHostStatuses() is synchronous — the IsAsyncReturningType filter must exclude it. const string userSrc = """ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Server { public sealed class SafeConnectivityCaller { public void DoIt(IHostConnectivityProbe probe) { _ = probe.GetHostStatuses(); } } } """; var diags = await Compile(userSrc); diags.ShouldBeEmpty(); } // ----------------------------------------------------------------------- // Concrete driver receiver with a renamed implementing method // ----------------------------------------------------------------------- [Fact] public async Task ConcreteDriver_RenamedReadMethod_StillTripsDiagnostic() { // A driver whose read implementation has a different name than ReadAsync but is the // explicit interface implementation — the FindImplementationForInterfaceMember branch // in ImplementsGuardedInterface must catch it. 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.Drivers.Fancy { public sealed class FancyDriver : IReadable { ValueTask> IReadable.ReadAsync(IReadOnlyList tags, CancellationToken ct) => InternalFetchAsync(tags, ct); public ValueTask> InternalFetchAsync(IReadOnlyList tags, CancellationToken ct) => throw null!; } } namespace ZB.MOM.WW.OtOpcUa.Server { using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Drivers.Fancy; public sealed class BadFancyCaller { public async Task DoIt(FancyDriver driver) { // Called through the interface — explicit implementation routes to IReadable.ReadAsync var iface = (IReadable)driver; var _ = await iface.ReadAsync(new List(), CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.Length.ShouldBe(1); diags[0].GetMessage().ShouldContain("ReadAsync"); } // ----------------------------------------------------------------------- // ExecuteWriteAsync wrapping // ----------------------------------------------------------------------- [Fact] public async Task Wrapped_WriteAsync_InsideExecuteWriteAsync_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 GoodWriteViaExecuteWrite { public async Task DoIt(IWritable driver, CapabilityInvoker invoker) { await invoker.ExecuteWriteAsync("h1", isIdempotent: false, async ct => await driver.WriteAsync(new List(), ct), CancellationToken.None); } } } """; var diags = await Compile(userSrc); diags.ShouldBeEmpty(); } // ----------------------------------------------------------------------- // Nested lambda — inner guarded call must not be suppressed by outer non-wrapper lambda // ----------------------------------------------------------------------- [Fact] public async Task GuardedCall_InsideNestedNonWrapperLambda_TripsDiagnostic() { // The ReadAsync call is inside a Func<...> that is NOT an argument to a wrapper method. // A naive ancestry walk that stops at any lambda would silently suppress this. const string userSrc = """ using System; 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 NestedLambdaCaller { public async Task DoIt(IReadable driver) { Func work = async () => { _ = await driver.ReadAsync(new List(), CancellationToken.None); }; await work(); } } } """; var diags = await Compile(userSrc); diags.Length.ShouldBe(1); diags[0].GetMessage().ShouldContain("ReadAsync"); } [Fact] public async Task GuardedCall_InsideWrapperLambda_InsideNonWrapperLambda_PassesCleanly() { // The ReadAsync call IS inside a CapabilityInvoker.ExecuteAsync lambda, // even though that lambda is also nested inside an outer non-wrapper lambda. const string userSrc = """ using System; 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 DoubleNestedGoodCaller { public async Task DoIt(IReadable driver, CapabilityInvoker invoker) { Func work = async () => { _ = await invoker.ExecuteAsync(DriverCapability.Read, "h1", async ct => await driver.ReadAsync(new List(), ct), CancellationToken.None); }; await work(); } } } """; var diags = await Compile(userSrc); diags.ShouldBeEmpty(); } 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(); } }