using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// #153 — confirm ModbusDriver emits structured warnings on first-fire of an /// auto-prohibition and informational logs on re-probe clearance. The logger plumbing /// extends through ModbusDriverFactoryExtensions.Register so production server-bootstrap /// paths get the logger automatically; here we exercise the constructor injection /// directly. /// [Trait("Category", "Unit")] public sealed class ModbusLoggerInjectionTests { private sealed class CapturingLogger : ILogger { public readonly List<(LogLevel Level, string Message)> Entries = 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))); private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } private sealed class ProtectedHoleTransport : IModbusTransport { public ushort ProtectedAddress { get; set; } = 102; public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { var addr = (ushort)((pdu[1] << 8) | pdu[2]); var qty = (ushort)((pdu[3] << 8) | pdu[4]); if (pdu[0] is 0x03 && ProtectedAddress >= addr && ProtectedAddress < addr + qty) return Task.FromException(new ModbusException(0x03, 0x02, "IllegalDataAddress")); var resp = new byte[2 + qty * 2]; resp[0] = pdu[0]; resp[1] = (byte)(qty * 2); return Task.FromResult(resp); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } [Fact] public async Task First_Failure_Emits_Single_Warning_Subsequent_Refire_Stays_Quiet() { var fake = new ProtectedHoleTransport(); var logger = new CapturingLogger(); var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "drv-logged", _ => fake, logger: logger); await drv.InitializeAsync("{}", CancellationToken.None); // Scan 1 — coalesced read fails. Expect exactly one warning. await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None); var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList(); warnings.Count.ShouldBe(1); warnings[0].Message.ShouldContain("drv-logged"); warnings[0].Message.ShouldContain("Start=100"); warnings[0].Message.ShouldContain("End=104"); // Scan 2 — same coalesced range still fails. Re-fire is suppressed (planner sees // the prohibition and skips the merge; even if it didn't, the de-dupe in // RecordAutoProhibition would suppress). await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None); logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1, "re-fire of same range stays silent"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Reprobe_Clearing_Prohibition_Emits_Information_Log() { var fake = new ProtectedHoleTransport(); var logger = new CapturingLogger(); var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5, AutoProhibitReprobeInterval = TimeSpan.FromHours(1), // long interval — we drive it manually Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "drv-logged", _ => fake, logger: logger); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None); // Operator unlocks the protected register; re-probe should clear + log. fake.ProtectedAddress = ushort.MaxValue; await drv.RunReprobeOnceForTestAsync(CancellationToken.None); var infoLogs = logger.Entries.Where(e => e.Level == LogLevel.Information && e.Message.Contains("cleared")).ToList(); infoLogs.Count.ShouldBeGreaterThanOrEqualTo(1, "re-probe success must emit a 'cleared' info log"); await drv.ShutdownAsync(CancellationToken.None); } }