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 { /// Verifies that the constructor accepts an ILogger. [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(); } /// Verifies that probe loop logs when an exception is swallowed. [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); // Wait for the probe loop's first swallowed-exception log instead of sleeping a fixed 250 ms: // on a loaded CI runner no tick had run yet and Entries was empty (#606). A presence wait — // it returns as soon as the entry lands, and a loop that never logs still fails below. var deadline = DateTime.UtcNow.AddSeconds(10); while (!HasProbeEntry(logger) && DateTime.UtcNow < deadline) await Task.Delay(10, TestContext.Current.CancellationToken); 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(); HasProbeEntry(logger) .ShouldBeTrue("at least one log entry should reference the probe loop or surface the swallowed exception"); } private static bool HasProbeEntry(CapturingLogger logger) => logger.Entries.Any(e => e.Message.Contains("probe", StringComparison.OrdinalIgnoreCase) || (e.Exception?.Message.Contains("simulated probe failure") ?? false)); private sealed class CapturingLogger : ILogger { private readonly List<(LogLevel Level, string Message, Exception? Exception)> _entries = new(); /// Gets a snapshot of the captured log entries (written from the probe loop's thread). public IReadOnlyList<(LogLevel Level, string Message, Exception? Exception)> Entries { get { lock (_entries) return _entries.ToArray(); } } /// Begins a logging scope. /// The scope state. /// The type of the state. public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; /// Checks if logging is enabled for the specified level. /// The log level. public bool IsEnabled(LogLevel logLevel) => true; /// Logs a message. /// The log level. /// The event ID. /// The state object. /// The exception, if any. /// The formatter function. /// The type of the state. public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { lock (_entries) _entries.Add((logLevel, formatter(state, exception), exception)); } private sealed class NullScope : IDisposable { /// Gets the singleton instance. public static NullScope Instance { get; } = new(); /// Disposes the scope. public void Dispose() { } } } }