feat(adminui): Transport dropdown on AbCip/AbLegacy driver forms (live-verified on docker-dev)

- AbCip/AbLegacy driver forms gain a Transport InputSelect; FromOptions/ToOptions now carry
  Transport, so saving any other field no longer silently reverts Native to libplctag.
- Both drivers log the constructed transport once per init.
- AbCip factory now takes the host ILoggerFactory (it ran on NullLogger in production, hiding
  every AbCip log line); DriverFactoryBootstrap passes it.
- Gate results: AdminUI /run (T19) native, inverted (dead port) and libplctag legs.
This commit is contained in:
Joseph Doherty
2026-10-07 19:05:50 -04:00
parent c8b35663e5
commit b8ebafc642
11 changed files with 359 additions and 11 deletions
@@ -0,0 +1,55 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.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 AbCipTransportInitLogTests
{
private static string Config(string transport) =>
$$"""{"transport":"{{transport}}","probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}]}""";
[Theory]
[InlineData("Native", AbCipTransport.Native)]
[InlineData("Libplctag", AbCipTransport.Libplctag)]
public async Task Init_logs_the_transport_through_the_registered_logger_factory(string json, AbCipTransport expected)
{
var captured = new List<string>();
using var factory = new CapturingLoggerFactory(captured);
var driver = AbCipDriverFactoryExtensions.CreateInstance("drv-t19", Config(json), factory);
try
{
await driver.InitializeAsync(Config(json), TestContext.Current.CancellationToken);
}
finally
{
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
}
captured.ShouldContain(m => m.Contains("drv-t19") && m.Contains($"transport={expected}"));
}
private sealed class CapturingLoggerFactory(List<string> sink) : ILoggerFactory
{
public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink);
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}
private sealed class CapturingLogger(List<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(formatter(state, exception));
}
}
}
@@ -0,0 +1,55 @@
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) =>
$$"""{"transport":"{{transport}}","probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""";
[Theory]
[InlineData("Native", AbLegacyTransport.Native)]
[InlineData("Libplctag", AbLegacyTransport.Libplctag)]
public async Task Init_logs_the_transport_through_the_registered_logger_factory(string json, AbLegacyTransport expected)
{
var captured = new List<string>();
using var factory = new CapturingLoggerFactory(captured);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", Config(json), factory);
try
{
await driver.InitializeAsync(Config(json), TestContext.Current.CancellationToken);
}
finally
{
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
}
captured.ShouldContain(m => m.Contains("drv-t19") && m.Contains($"transport={expected}"));
}
private sealed class CapturingLoggerFactory(List<string> sink) : ILoggerFactory
{
public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink);
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}
private sealed class CapturingLogger(List<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(formatter(state, exception));
}
}
}