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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,4 +100,58 @@ public sealed class AbCipDriverPageFormSerializationTests
|
||||
back.Devices[0].PlcFamily.ShouldBe(AbCipPlcFamily.ControlLogix);
|
||||
back.Devices[1].PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix);
|
||||
}
|
||||
|
||||
// ---- Task 19: Transport (native AB transport switch) ----
|
||||
// These drive the form's OWN load/save path (TryDeserialize -> FromOptions -> ToOptions -> Serialize),
|
||||
// which uses the production JsonSerializerOptions, so a numeric enum or a dropped field shows up here.
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipTransport.Libplctag)]
|
||||
[InlineData(AbCipTransport.Native)]
|
||||
public void Transport_survives_FromOptions_ToOptions(AbCipTransport transport)
|
||||
{
|
||||
var original = new AbCipDriverOptions { Transport = transport };
|
||||
var back = AbCipDriverForm.FormModel.FromOptions(original).ToOptions([]);
|
||||
back.Transport.ShouldBe(transport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Native_transport_serializes_as_a_string()
|
||||
{
|
||||
var form = new AbCipDriverForm.FormModel { Transport = AbCipTransport.Native };
|
||||
var json = AbCipDriverForm.Serialize(form.ToOptions([]));
|
||||
json.ShouldContain("\"transport\":\"Native\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editing_an_unrelated_field_preserves_a_saved_Native_transport()
|
||||
{
|
||||
// The regression the review found: FromOptions/ToOptions dropped Transport, so an operator who set
|
||||
// Native was silently reverted to libplctag the next time they saved ANY other edit.
|
||||
const string saved = """{"transport":"Native","timeout":"00:00:02","devices":[]}""";
|
||||
var loaded = AbCipDriverForm.TryDeserialize(saved);
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Transport.ShouldBe(AbCipTransport.Native);
|
||||
|
||||
var form = AbCipDriverForm.FormModel.FromOptions(loaded);
|
||||
form.TimeoutSeconds = 7;
|
||||
var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices));
|
||||
|
||||
json.ShouldContain("\"transport\":\"Native\"");
|
||||
json.ShouldContain("\"timeout\":\"00:00:07\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_config_without_transport_loads_as_Libplctag_and_re_emits_it_explicitly()
|
||||
{
|
||||
// Chosen behaviour: an absent key round-trips as an explicit "Libplctag" (the form always writes the
|
||||
// field). The dropdown therefore shows "libplctag (default)".
|
||||
var loaded = AbCipDriverForm.TryDeserialize("""{"probeTimeoutSeconds":5}""");
|
||||
loaded.ShouldNotBeNull();
|
||||
var form = AbCipDriverForm.FormModel.FromOptions(loaded);
|
||||
form.Transport.ShouldBe(AbCipTransport.Libplctag);
|
||||
|
||||
var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices));
|
||||
json.ShouldContain("\"transport\":\"Libplctag\"");
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -96,4 +96,58 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
||||
back.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Slc500);
|
||||
back.Devices[1].PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix);
|
||||
}
|
||||
|
||||
// ---- Task 19: Transport (native AB transport switch) ----
|
||||
// These drive the form's OWN load/save path (TryDeserialize -> FromOptions -> ToOptions -> Serialize),
|
||||
// which uses the production JsonSerializerOptions, so a numeric enum or a dropped field shows up here.
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyTransport.Libplctag)]
|
||||
[InlineData(AbLegacyTransport.Native)]
|
||||
public void Transport_survives_FromOptions_ToOptions(AbLegacyTransport transport)
|
||||
{
|
||||
var original = new AbLegacyDriverOptions { Transport = transport };
|
||||
var back = AbLegacyDriverForm.FormModel.FromOptions(original).ToOptions([]);
|
||||
back.Transport.ShouldBe(transport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Native_transport_serializes_as_a_string()
|
||||
{
|
||||
var form = new AbLegacyDriverForm.FormModel { Transport = AbLegacyTransport.Native };
|
||||
var json = AbLegacyDriverForm.Serialize(form.ToOptions([]));
|
||||
json.ShouldContain("\"transport\":\"Native\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editing_an_unrelated_field_preserves_a_saved_Native_transport()
|
||||
{
|
||||
// The regression the review found: FromOptions/ToOptions dropped Transport, so an operator who set
|
||||
// Native was silently reverted to libplctag the next time they saved ANY other edit.
|
||||
const string saved = """{"transport":"Native","timeout":"00:00:02","devices":[]}""";
|
||||
var loaded = AbLegacyDriverForm.TryDeserialize(saved);
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Transport.ShouldBe(AbLegacyTransport.Native);
|
||||
|
||||
var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
|
||||
form.TimeoutSeconds = 7;
|
||||
var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices));
|
||||
|
||||
json.ShouldContain("\"transport\":\"Native\"");
|
||||
json.ShouldContain("\"timeout\":\"00:00:07\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_config_without_transport_loads_as_Libplctag_and_re_emits_it_explicitly()
|
||||
{
|
||||
// Chosen behaviour: an absent key round-trips as an explicit "Libplctag" (the form always writes the
|
||||
// field). The dropdown therefore shows "libplctag (default)".
|
||||
var loaded = AbLegacyDriverForm.TryDeserialize("""{"probeTimeoutSeconds":5}""");
|
||||
loaded.ShouldNotBeNull();
|
||||
var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
|
||||
form.Transport.ShouldBe(AbLegacyTransport.Libplctag);
|
||||
|
||||
var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices));
|
||||
json.ShouldContain("\"transport\":\"Libplctag\"");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user