using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Regression coverage for Driver.AbCip-007 — the driver previously swallowed every /// exception in its read / write / probe / template-read / alarm-poll paths with no /// logging at all, leaving operators blind when a PLC was silently failing every tick. /// Driver.AbCip-011 — when the probe is Enabled but no ProbeTagPath is configured, the /// driver used to silently leave every device's HostState=Unknown forever; the fix logs a /// warning at init time so the operator notices. /// [Trait("Category", "Unit")] public sealed class AbCipLoggingTests { private const string Device = "ab://10.0.0.5/1,0"; [Fact] public void Constructor_accepts_an_ILogger() { // Constructor signature must allow an ILogger so the host can wire one // through Microsoft.Extensions.DependencyInjection. The driver code project already // pulls in Microsoft.Extensions.Logging.Abstractions transitively via Core. var logger = new CapturingLogger(); var drv = new AbCipDriver( new AbCipDriverOptions { Probe = new AbCipProbeOptions { Enabled = false } }, "drv-1", logger: logger); drv.ShouldNotBeNull(); } [Fact] public async Task ProbeLoop_logs_when_an_exception_is_swallowed() { var logger = new CapturingLogger(); var factory = new FakeAbCipTagFactory { // Force the probe to throw on initialize so the swallow path runs every tick. Customise = p => new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new InvalidOperationException("simulated probe init failure"), }, }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = "ProbeTag", Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromMilliseconds(50), }, }, "drv-log", factory, logger: logger); await drv.InitializeAsync("{}", CancellationToken.None); // Give the probe loop a couple of ticks to log. await Task.Delay(200); await drv.ShutdownAsync(CancellationToken.None); // We expect at least one log entry that mentions the probe loop or carries the // simulated exception. Without it there is no record of a wedged probe — exactly the // gap the finding called out. logger.Entries.ShouldNotBeEmpty(); logger.Entries.Any(e => e.Message.Contains("probe", StringComparison.OrdinalIgnoreCase) || (e.Exception?.Message.Contains("simulated probe init failure") ?? false)) .ShouldBeTrue("at least one log entry should reference the probe loop or surface the swallowed exception"); } [Fact] public async Task ReadFailure_logs_at_warning_level() { // A non-zero libplctag status used to be silently classified into BadCommunicationError // with no log. After the fix the driver logs a warning so operators can correlate the // status code with the affected tag. var logger = new CapturingLogger(); var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = (int)libplctag.Status.ErrorBadConnection }, }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory, logger: logger); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["Speed"], CancellationToken.None); logger.Entries.Any(e => e.Level >= LogLevel.Warning && e.Message.Contains("Speed", StringComparison.OrdinalIgnoreCase)) .ShouldBeTrue("read failure on tag 'Speed' should be logged at warning level or above"); } [Fact] public async Task ReadException_logs_at_warning_level() { // A transport-level exception used to be silently mapped to BadCommunicationError with // no log. After the fix the driver logs a warning carrying the exception so operators // can see the root cause. var logger = new CapturingLogger(); var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true, Exception = new InvalidOperationException("simulated wire failure"), }, }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory, logger: logger); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["Speed"], CancellationToken.None); logger.Entries.Any(e => e.Level >= LogLevel.Warning && (e.Exception?.Message.Contains("simulated wire failure") ?? false)) .ShouldBeTrue("read transport exception should be logged at warning level with the inner exception attached"); } [Fact] public async Task InitializeAsync_warns_when_probe_is_enabled_but_ProbeTagPath_is_blank() { // Driver.AbCip-011: when Probe.Enabled is true but ProbeTagPath is null/blank, the // driver used to start no probe loop and leave the device's HostState=Unknown forever. // The fix logs a warning so the operator sees the misconfiguration instead of getting // a silently inert health surface. var logger = new CapturingLogger(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = null, // explicitly inert }, }, "drv-1", logger: logger); await drv.InitializeAsync("{}", CancellationToken.None); logger.Entries.Any(e => e.Level == LogLevel.Warning && (e.Message.Contains("probe", StringComparison.OrdinalIgnoreCase) && e.Message.Contains("ProbeTagPath", StringComparison.OrdinalIgnoreCase))) .ShouldBeTrue("probe-enabled-but-inert configuration should be logged at warning level"); } [Fact] public async Task InitializeAsync_does_not_warn_when_probe_is_disabled() { // No warning when the operator explicitly opted out. var logger = new CapturingLogger(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", logger: logger); await drv.InitializeAsync("{}", CancellationToken.None); logger.Entries.Any(e => e.Level == LogLevel.Warning && e.Message.Contains("ProbeTagPath", StringComparison.OrdinalIgnoreCase)) .ShouldBeFalse("no probe warning expected when Probe.Enabled is false"); } internal sealed class CapturingLogger : ILogger { public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new(); public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add((logLevel, formatter(state, exception), exception)); } private sealed class NullScope : IDisposable { public static NullScope Instance { get; } = new(); public void Dispose() { } } } }