using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Regression coverage for Driver.FOCAS-007 — the driver previously swallowed every /// exception in its poll / probe / recycle / fixed-tree loops with no logging at all, /// leaving operators blind when a CNC was silently failing every tick. /// [Trait("Category", "Unit")] public sealed class FocasLoggingTests { [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 // references Microsoft.Extensions.Logging.Abstractions. var logger = new CapturingLogger(); var drv = new FocasDriver( new FocasDriverOptions { Probe = new FocasProbeOptions { Enabled = false } }, "drv-1", new FakeFocasClientFactory(), logger); drv.ShouldNotBeNull(); } [Fact] public async Task ProbeLoop_logs_when_an_exception_is_swallowed() { var logger = new CapturingLogger(); var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { // Make ProbeAsync throw — the probe loop swallows it but must log. ThrowOnConnect = false, ProbeResult = true, // not used because the underlying probe path throws }, }; // Force the probe to throw by making the client throw on connect. factory.Customise = () => new FakeFocasClient { ThrowOnConnect = true, Exception = new InvalidOperationException("simulated probe failure"), }; var drv = new FocasDriver( new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(100), }, }, "drv-log", factory, logger); await drv.InitializeAsync("{}", CancellationToken.None); // Give the probe loop one tick or two to log. await Task.Delay(250); await drv.ShutdownAsync(CancellationToken.None); // We expect at least one log entry at Debug / Warning that mentions the simulated // failure or the probe loop. Without logging there's literally no record on a wedged // CNC — 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 failure") ?? false)) .ShouldBeTrue("at least one log entry should reference the probe loop or surface the swallowed exception"); } private 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() { } } } }