Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs
T
Joseph Doherty 1806af54f0 fix(ab): Task 22 review r1 — release-note timing and AbLegacy regressions, probe E2E on both transports
- I1: the upgrade restart itself moves every no-key AbCip/AbLegacy driver to Native (every process
  start constructs drivers, incl. boot-from-cache). Docs + CLAUDE.md now say so and tell operators
  to set "transport": "Libplctag" BEFORE upgrading (older builds ignore the unknown key).
- I2: AbLegacy release note leads with the status-bit write refusal (T4:0.DN, C5:0.CU, R6:0.EN, ...)
  on defaulted SLC/MicroLogix drivers and the fallback. The real client status is
  BadCommunicationError, not BadNotSupported (the driver's write catch-all maps the runtime's
  AbTransportException like any transport failure, as #571 does for PLC-5), with handle eviction
  and Degraded health; pinned by a driver-level test (no 0xAA write on the wire, with a positive
  control). ST string byte order stated as unverified. Also corrects the docs' "T4:0/DN" examples:
  the driver's address grammar is "T4:0.DN" ("/DN" is a malformed address).
- I3: DriverProbeHandshakeE2eTests' two AbCip probe tests are a [Theory] over Native and
  Libplctag. Run against ab_server controllogix: all 4 rows pass with identical exact messages.
- M1: documented (docs + resolver XML doc): the AdminUI merges only the probed device, so a
  mixed-family defaulted AbLegacy driver can probe Native while running Libplctag.
- M2: factory ParseEnum rejects undefined integer strings ("7") via Enum.IsDefined (both drivers).
- M3: AbCip driver-level bad value reads "AB CIP config in '<id>' has unknown Transport ..."; the
  named constant is gone (tagName: null now means driver-level).
- M4: the explicit-Native limited-family Warning is once per instance, not per (re)init.
- Nit: EffectiveTransport docs note that injected factories bypass it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:06:40 -04:00

113 lines
5.2 KiB
C#

using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// The transport the instance runs on is logged at every init through the HOST-supplied logger factory,
/// so an operator can tell from the node log which stack serves a driver (T19 live gate). The factory
/// path is the one under test: a driver built without a logger writes to the null logger.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyTransportInitLogTests
{
private static string Config(string? transport, string family = "Slc500") =>
$$"""{{{(transport is null ? "" : $"\"transport\":\"{transport}\",")}}"probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"{{family}}"}]}""";
/// <summary>The init line names the configured-vs-effective transport (Task 22).</summary>
[Theory]
[InlineData("Native", "Slc500", "Native (configured)")]
[InlineData("Libplctag", "Slc500", "Libplctag (configured)")]
[InlineData(null, "Slc500", "Native (default)")]
[InlineData(null, "Plc5", "Libplctag (default; PLC-5/LogixPccc device present)")]
public async Task Init_logs_the_transport_through_the_registered_logger_factory(
string? json, string family, string expected)
{
var captured = await InitAndCaptureAsync(Config(json, family));
captured.ShouldContain(m => m.Message.Contains("drv-t19") && m.Message.Contains($"transport={expected}"));
}
/// <summary>
/// An explicit Native against a PLC-5 / LogixPccc device is an allowed operator choice, but logs ONE
/// Warning naming the limitation. The defaulted and explicit-libplctag rows are the falsification
/// controls: same device families, no warning.
/// </summary>
[Theory]
[InlineData("Native", "Plc5", true)]
[InlineData("Native", "LogixPccc", true)]
[InlineData(null, "Plc5", false)]
[InlineData("Libplctag", "LogixPccc", false)]
[InlineData("Native", "Slc500", false)]
public async Task Explicit_Native_on_a_limited_family_warns_once(string? json, string family, bool warns)
{
var captured = await InitAndCaptureAsync(Config(json, family));
var warnings = captured.Where(m => m.Level == LogLevel.Warning && m.Message.Contains("native support is limited"))
.ToList();
warnings.Count.ShouldBe(warns ? 1 : 0);
if (warns) warnings[0].Message.ShouldContain(family);
}
/// <summary>
/// The Warning is once per INSTANCE, not per init: every comms-loss reconnect retry re-runs init
/// (Task 22 review r1 M4). Three inits, one warning.
/// </summary>
[Fact]
public async Task The_limited_family_warning_is_logged_once_per_instance_across_reinits()
{
var config = Config("Native", "Plc5");
var captured = new List<(LogLevel, string)>();
using var factory = new CapturingLoggerFactory(captured);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", config, factory);
try
{
await driver.InitializeAsync(config, TestContext.Current.CancellationToken);
await driver.ReinitializeAsync(config, TestContext.Current.CancellationToken);
await driver.ReinitializeAsync(config, TestContext.Current.CancellationToken);
}
finally
{
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
}
List<(LogLevel Level, string Message)> snapshot;
lock (captured) snapshot = [.. captured];
snapshot.Count(m => m.Message.Contains("initializing")).ShouldBe(3); // positive control: three inits ran
snapshot.Count(m => m.Level == LogLevel.Warning && m.Message.Contains("native support is limited")).ShouldBe(1);
}
private static async Task<List<(LogLevel Level, string Message)>> InitAndCaptureAsync(string config)
{
var captured = new List<(LogLevel, string)>();
using var factory = new CapturingLoggerFactory(captured);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", config, factory);
try
{
await driver.InitializeAsync(config, TestContext.Current.CancellationToken);
}
finally
{
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
}
lock (captured) return [.. captured];
}
private sealed class CapturingLoggerFactory(List<(LogLevel, string)> sink) : ILoggerFactory
{
public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink);
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}
private sealed class CapturingLogger(List<(LogLevel, string)> sink) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (sink) sink.Add((logLevel, formatter(state, exception)));
}
}
}