Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLoggerInjectionTests.cs
T
Joseph Doherty 620ecc41dd fix(modbus): refusals are per-tag Bad, batch health, generation-stamped lifecycle, write status mapping
H4: exception 01/02/03 (and undecodable data) are device refusals - per-tag
Bad with a once-per-tag Warning, no Degraded, and they advance
LastSuccessfulRead (the device answered), so an all-refused driver no longer
trips the 90 s watchdog. 04/05/06 still degrade (answered); 0A/0B degrade
without advancing. Health is one worst-wins write per batch instead of
last-writer-wins per tag. A comms failure on one unit fails the rest of
that unit's batch fast; an unreachable endpoint fails the whole batch fast.
H7: write transport failures map to BadCommunicationError, value overflow and
array length mismatch to BadOutOfRange, conversion failures to
BadTypeMismatch; writes report write-side health (a good write or a refusal
clears a write-side Degraded; a good read does not).
Lifecycle: every health write and the transport/table publish carry a
generation; a superseded init releases its transport and publishes nothing,
a read straddling a re-init cannot degrade the new transport, and the
shutdown's final write cannot clobber a newer init. The tag table is
build-and-swapped (re-init no longer merges into the old table). Teardown
joins the probe/re-probe loops (bounded); a probe answer advances
LastSuccessfulRead and an exception PDU from the device counts as Running.
Transports latch dispose atomically and never reconnect after it.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:15:20 -04:00

142 lines
8.0 KiB
C#

using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #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.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusLoggerInjectionTests
{
/// <summary>Test logger that captures log entries.</summary>
private sealed class CapturingLogger : ILogger<ModbusDriver>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
/// <summary>Begins a scope for logging.</summary>
/// <typeparam name="TState">The type of the state.</typeparam>
/// <param name="state">The state object.</param>
/// <returns>A disposable scope instance.</returns>
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <summary>Determines if logging is enabled for the specified level.</summary>
/// <param name="logLevel">The log level to check.</param>
/// <returns>True if logging is enabled for the specified level.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Logs a message with the specified level, event ID, state, exception, and formatter.</summary>
/// <typeparam name="TState">The type of the state.</typeparam>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event ID.</param>
/// <param name="state">The state object.</param>
/// <param name="exception">The exception, if any.</param>
/// <param name="formatter">The function to format the log message.</param>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
/// <summary>Disposes the logger.</summary>
public void Dispose() { }
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
/// <inheritdoc />
public void Dispose() { }
}
}
/// <summary>Test transport with a protected address hole.</summary>
private sealed class ProtectedHoleTransport : IModbusTransport
{
/// <summary>Gets or sets the protected address.</summary>
public ushort ProtectedAddress { get; set; } = 102;
/// <summary>Simulates connecting to the Modbus device.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>A completed task.</returns>
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Simulates sending a Modbus PDU.</summary>
/// <param name="unitId">The Modbus unit ID.</param>
/// <param name="pdu">The protocol data unit.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The response PDU.</returns>
public Task<byte[]> 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<byte[]>(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);
}
/// <summary>Disposes the transport asynchronously.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies first failure emits single warning and subsequent refires stay quiet.</summary>
[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", RawTags = ModbusRawTags.Entries([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 && e.Message.Contains("auto-prohibited")).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 && e.Message.Contains("auto-prohibited"))
.ShouldBe(1, "re-fire of same range stays silent");
// The protected register's per-tag fallback is a device refusal: one Warning for that tag across
// both scans (once-per-tag), and none for its readable neighbours.
var refusals = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("refused")).ToList();
refusals.Count.ShouldBe(1);
refusals[0].Message.ShouldContain("T102");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies reprobe clearing prohibition emits information log.</summary>
[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", RawTags = ModbusRawTags.Entries([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);
}
}