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
@@ -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\"");
}
}
@@ -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\"");
}
}