fix(driver-abcip): resolve Low code-review findings (Driver.AbCip-007,011,012,013,015)
- Driver.AbCip-007: inject an optional ILogger<AbCipDriver> / ILogger<AbCipAlarmProjection> (default NullLogger) and log around every read / write / template-fetch / probe / alarm-poll failure path. - Driver.AbCip-011: LogWarning when InitializeAsync is configured with Probe.Enabled=true but ProbeTagPath is blank — operators now see why GetHostStatuses keeps reporting Unknown. - Driver.AbCip-012: documented the LibplctagTemplateReader per-call Tag cost as accepted given libplctag's own connection pool and the low-frequency discovery use-case. - Driver.AbCip-013: per-device AllowPacking + ConnectionSize overrides on AbCipDeviceOptions, threaded through AbCipTagCreateParams; central BuildCreateParams helper replaces five ad-hoc clones; AllowPacking now reaches Tag.AllowPacking at runtime. - Driver.AbCip-015: stale-comment sweep — every PR-N forward-reference is rewritten to describe present behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<AbCipDriver> 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<AbCipDriver>();
|
||||
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<AbCipDriver>();
|
||||
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<AbCipDriver>();
|
||||
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<AbCipDriver>();
|
||||
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<AbCipDriver>();
|
||||
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<AbCipDriver>();
|
||||
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<T> : ILogger<T>
|
||||
{
|
||||
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static NullScope Instance { get; } = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user