fix(driver-ablegacy): resolve Low code-review findings (Driver.AbLegacy-005,011,013)

- Driver.AbLegacy-005: optional ILogger<AbLegacyDriver> ctor parameter,
  logged init failure / probe transitions / first non-zero libplctag
  status per device.
- Driver.AbLegacy-011: Dispose() runs the synchronous teardown directly
  instead of bridging via DisposeAsync().AsTask().GetAwaiter().GetResult()
  to remove the documented sync-over-async deadlock pattern.
- Driver.AbLegacy-013: documented the ResolveHost three-tier fallback
  chain in XML and pointed DiscoverAsync's IsArray=false comment at the
  Modbus ArrayCount pattern for the eventual multi-element follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 07:45:31 -04:00
parent 77b8686199
commit f7e3e9885e
5 changed files with 404 additions and 12 deletions

View File

@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Driver.AbLegacy-005 — verifies the driver accepts and uses an optional
/// <see cref="ILogger{AbLegacyDriver}"/>. Probe transitions, init failures, and the first
/// non-zero libplctag status per device must be logged (rather than only folded into the
/// transient <see cref="DriverHealth.Detail"/> string) so a field operator can correlate a
/// PCCC comms problem with a structured log entry.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyLoggerInjectionTests
{
private sealed class CapturingLogger : ILogger<AbLegacyDriver>
{
public readonly List<(LogLevel Level, string Message)> Entries = 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)));
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
[Fact]
public void Driver_accepts_optional_logger_parameter()
{
// Constructor must accept an ILogger<AbLegacyDriver> as an optional named arg, matching
// the Modbus/S7/Galaxy driver pattern. The driver runs with NullLogger when omitted.
var logger = new CapturingLogger();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-logged", tagFactory: null, logger: logger);
drv.ShouldNotBeNull();
}
[Fact]
public async Task InitializeAsync_failure_emits_error_log()
{
var logger = new CapturingLogger();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("not-a-valid-address")],
}, "drv-logged", tagFactory: null, logger: logger);
await Should.ThrowAsync<InvalidOperationException>(
() => drv.InitializeAsync("{}", CancellationToken.None));
var errors = logger.Entries.Where(e => e.Level >= LogLevel.Error).ToList();
errors.ShouldNotBeEmpty("init failure must surface as an Error-level log");
errors[0].Message.ShouldContain("drv-logged");
}
[Fact]
public async Task First_nonzero_libplctag_status_per_device_is_logged()
{
// The driver should log the first occurrence of a non-zero libplctag status per device,
// so that a field operator can correlate a comms problem with a structured log entry
// even though Detail is overwritten by the next read.
var factory = new FakeAbLegacyTagFactory();
var logger = new CapturingLogger();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-logged", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Status = -32 }; // timeout
await drv.ReadAsync(["X"], CancellationToken.None);
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
warnings.ShouldNotBeEmpty("first non-zero libplctag status must emit a structured warning");
warnings.Any(w => w.Message.Contains("ab://10.0.0.5/1,0") || w.Message.Contains("drv-logged"))
.ShouldBeTrue("warning must identify the device or driver instance");
// Second read with the same status — the per-device de-dupe should suppress.
var warningCountAfterFirst = warnings.Count;
await drv.ReadAsync(["X"], CancellationToken.None);
var newWarnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
newWarnings.Count.ShouldBe(warningCountAfterFirst, "subsequent same-device non-zero status stays quiet");
}
}