diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md index 06826e51..3d2570e2 100644 --- a/docs/plans/2026-10-07-native-ab-transport-gate-results.md +++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md @@ -187,7 +187,76 @@ see the same split on the press. ## AdminUI /run (T19) -_(pending)_ +Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture +on `10.100.0.35:44818`. Every container ran the same freshly built image +(`otopcua-host:dev` `sha256:91bffbb0…`, checked per container because `:9200` round-robins). The +modal was driven with the Chrome tools against `http://localhost:9200/raw`. + +**Authoring (UI).** +- Created the AbCip driver `t19-abcip` (`DRV-8ea0f0a8cfde`) in the MAIN cluster. +- Configure → Transport = "Native (managed)" → Save. `DriverInstance.DriverConfig` then held + `"transport":"Native"`, a string. +- Reopened Configure. The dropdown showed "Native (managed)". Changed only Timeout from 2 to 3 and + saved. The row then held `"transport":"Native"` and `"timeout":"00:00:03"`, so the save-reverts-transport + defect is fixed live, not only in unit tests. +- Created device `Device1plc1` with host `ab://10.100.0.35:44818/1,0` (ControlLogix). Test Connect + returned `OK · 62 ms`. +- Added the raw tag `TestDINT` (Int32, read-only, tag path `TestDINT`, DInt). +- Seeded `TestDINT = 4242` on the simulator so a non-zero value proves the read path. +- Raw namespace: `ns=2`, the shared `s=OtOpcUa` root plus RawPath ids. UNS uses `ns=3`. +- RawPath: `t19-abcip/Device1plc1/TestDINT`. + +**Leg 1: Native.** The deploy sealed with `acks=6`. Both central nodes logged: +``` +AbCip driver DRV-8ea0f0a8cfde initializing: transport=Native tagFactory=NativeAbCipTagFactory +``` +CLI read on central-1 (`:4840`) and central-2 (`:4841`): +``` +Node: ns=2;s=t19-abcip/Device1plc1/TestDINT +Value: 4242 +Status: 0x00000000 +``` +The simulator value was then changed to 5150 through the libplctag CLI. The native-served node +followed it (`Value: 5150`, `Status: 0x00000000`, source time `23:02:09.60Z`). + +**Leg 2: inverted gate (Native, dead port).** +- Device host changed to `ab://10.100.0.35:44819/1,0`. Nothing listens on 44819 + (`ss -ltn` count 0). +- The node went `BadNoCommunication` (value retained). A CLI read failed with + `ServiceResultException: [80050000]` (BadCommunicationError). The CLI `read` throws on a Bad + status rather than printing it. +- The central-1 log showed the native error text, raised from `NativeAbCipTagRuntime.ReadAsync`: + ``` + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: TCP connect to 10.100.0.35:44819 failed: ConnectionRefused + ---> System.Net.Sockets.SocketException (111): Connection refused + ``` +- For contrast, the same dead port under libplctag logged different wording: + ``` + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: ErrorTimeout + ---> libplctag.LibPlcTagException: ErrorTimeout + ``` + So the switch changes behaviour and is not inert. + +**Leg 3: back to libplctag.** +- Device restored to port 44818. Configure → Transport = "libplctag (default)" → Save. The row held + `"transport":"Libplctag"`. +- The redeploy sealed with `acks=6`. Both centrals logged + `initializing: transport=Libplctag tagFactory=LibplctagTagFactory`. +- CLI reads on `:4840` and `:4841`: `Value: 5150`, `Status: 0x00000000`. + +**Fix found by the gate.** The first deploy logged no transport line. `AbCipDriverFactoryExtensions.Register` +took no `ILoggerFactory`, and `DriverFactoryBootstrap` registered AbCip without one, so every production +AbCip driver ran on `NullLogger`. That hid the new line and every existing AbCip warning or error. AbCip +now takes the host logger factory the same way AbLegacy and Modbus do. `AbCipTransportInitLogTests` is +falsified by reverting that wiring: 2/2 tests fail. + +**Cleanup.** +- The `t19-abcip` driver, device and tag rows were deleted and the change redeployed (sealed, + `acks=6`). Both centrals logged `stopped driver child DRV-8ea0f0a8cfde`. +- The rig has no test driver pointing at the fixture. +- The fixture was brought down (`docker compose --profile controllogix down`). +- The simulator's `TestDINT` was left at 5150 until teardown. The container is gone, so the value + does not persist. ## Real press (T21) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 99f400c2..39c3985b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -152,6 +152,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, _driverInstanceId = driverInstanceId; // Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed // Transport in a reinit config is inert; a deploy recreates the instance instead. + _transport = options.Transport; var defaults = AbCipTransportFactories.For(options.Transport); _tagFactory = tagFactory ?? defaults.Tags; _enumeratorFactory = enumeratorFactory ?? defaults.Enumerators; @@ -178,6 +179,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// The tag factory chosen at construction; exposed so tests can pin transport selection. internal IAbCipTagFactory TagFactory => _tagFactory; + // The transport chosen at construction (see the ctor); logged at every init so an operator can tell + // from the node log which stack serves this instance. + private readonly AbCipTransport _transport; + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); @@ -282,6 +287,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); + _logger.LogInformation( + "AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}", + _driverInstanceId, _transport, _tagFactory.GetType().Name); try { if (!string.IsNullOrWhiteSpace(driverConfigJson)) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs index 4ce4ca72..7819ec72 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; @@ -15,13 +16,18 @@ public static class AbCipDriverFactoryExtensions public const string DriverTypeName = "AbCip"; /// - /// Registers the AB CIP driver factory with the driver registry. + /// Registers the AB CIP driver factory with the driver registry. The optional + /// is captured at registration time and used to construct an + /// per driver instance — without it the driver runs with the + /// null logger, so none of its diagnostics (including the per-init transport line) reach the + /// node log. Mirrors the AbLegacy / Modbus registration pattern. /// /// The driver factory registry to register with. - public static void Register(DriverFactoryRegistry registry) + /// Optional logger factory for driver instances. + public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) { ArgumentNullException.ThrowIfNull(registry); - registry.Register(DriverTypeName, CreateInstance); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); } /// @@ -31,15 +37,24 @@ public static class AbCipDriverFactoryExtensions /// The driver configuration as a JSON string. /// A configured instance. internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson) + => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); + + /// Creates an instance of the AB CIP driver with an optional logger. + /// The unique identifier for this driver instance. + /// The driver configuration as a JSON string. + /// Optional logger factory for the driver instance. + /// A configured instance. + internal static AbCipDriver CreateInstance( + string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); var options = ParseOptions(driverInstanceId, driverConfigJson); - return new AbCipDriver(options, driverInstanceId); + return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger()); } /// /// Deserialise an AB CIP driver-config JSON document into . - /// Shared by (first construction) and + /// Shared by (first construction) and /// / /// so a reinitialize with a changed config JSON (new device, new tag, changed timeout) /// actually takes effect rather than being silently discarded. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 07eae5d1..a6b1dcf8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -70,6 +70,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover _driverInstanceId = driverInstanceId; // Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed // Transport in a reinit config is inert; a deploy recreates the instance instead. + _transport = options.Transport; _tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory()); @@ -92,6 +93,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// The tag factory chosen at construction; exposed so tests can pin transport selection. internal IAbLegacyTagFactory TagFactory => _tagFactory; + // The transport chosen at construction (see the ctor); logged at every init so an operator can tell + // from the node log which stack serves this instance. + private readonly AbLegacyTransport _transport; + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); @@ -129,6 +134,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); + _logger.LogInformation( + "AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}", + _driverInstanceId, _transport, _tagFactory.GetType().Name); try { // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor index 2a422476..616ad7d4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor @@ -32,6 +32,14 @@
Only enable when UDT member declaration order matches controller compiled layout.
+
+ + + + + +
Which protocol stack talks to the controller. Takes effect on the next deploy.
+
@@ -136,7 +144,11 @@ /// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array /// from the device rows at deploy/probe time).
public string GetConfigJson() - => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts); + => Serialize(_form.ToOptions(_preservedDevices)); + + /// Serializes options with the form's JSON settings (camelCase, enums as strings). + internal static string Serialize(AbCipDriverOptions options) + => JsonSerializer.Serialize(options, _jsonOpts); private async Task EmitAsync() { @@ -151,7 +163,7 @@ await ResilienceConfigChanged.InvokeAsync(r); } - private static AbCipDriverOptions? TryDeserialize(string json) + internal static AbCipDriverOptions? TryDeserialize(string json) { try { return JsonSerializer.Deserialize(json, _jsonOpts); } catch { return null; } @@ -163,6 +175,7 @@ { // Operation public int TimeoutSeconds { get; set; } = 2; + public AbCipTransport Transport { get; set; } = AbCipTransport.Libplctag; public bool EnableControllerBrowse { get; set; } = false; public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false; @@ -182,6 +195,7 @@ public static FormModel FromOptions(AbCipDriverOptions o) => new() { TimeoutSeconds = (int)o.Timeout.TotalSeconds, + Transport = o.Transport, EnableControllerBrowse = o.EnableControllerBrowse, EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping, EnableAlarmProjection = o.EnableAlarmProjection, @@ -205,6 +219,7 @@ ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath, }, Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + Transport = Transport, EnableControllerBrowse = EnableControllerBrowse, EnableAlarmProjection = EnableAlarmProjection, AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor index 58ccebb3..b3bd135e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor @@ -19,6 +19,14 @@
Default read/write timeout. Default 2 s.
+
+ + + + + +
Which protocol stack talks to the controller. Takes effect on the next deploy.
+
@@ -101,7 +109,11 @@ /// Serializes the current channel/protocol config to camelCase JSON, preserving the driver's /// existing Devices array (authored as separate v3 entities). public string GetConfigJson() - => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts); + => Serialize(_form.ToOptions(_preservedDevices)); + + /// Serializes options with the form's JSON settings (camelCase, enums as strings). + internal static string Serialize(AbLegacyDriverOptions options) + => JsonSerializer.Serialize(options, _jsonOpts); private async Task EmitAsync() { @@ -116,7 +128,7 @@ await ResilienceConfigChanged.InvokeAsync(r); } - private static AbLegacyDriverOptions? TryDeserialize(string json) + internal static AbLegacyDriverOptions? TryDeserialize(string json) { try { return JsonSerializer.Deserialize(json, _jsonOpts); } catch { return null; } @@ -128,6 +140,7 @@ { // Operation public int TimeoutSeconds { get; set; } = 2; + public AbLegacyTransport Transport { get; set; } = AbLegacyTransport.Libplctag; // Probe public bool ProbeEnabled { get; set; } = true; @@ -145,6 +158,7 @@ public static FormModel FromOptions(AbLegacyDriverOptions o) => new() { TimeoutSeconds = (int)o.Timeout.TotalSeconds, + Transport = o.Transport, ProbeEnabled = o.Probe.Enabled, ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, @@ -164,6 +178,7 @@ ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress, }, Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + Transport = Transport, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, }; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 4943dee6..94b8b31c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -168,7 +168,7 @@ public static class DriverFactoryBootstrap IDriverPrimaryRoleSource primaryRoleSource, ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null) { - Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry); + Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory); Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory); Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot); Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportInitLogTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportInitLogTests.cs new file mode 100644 index 00000000..3c54b962 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportInitLogTests.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// 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. +/// +[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(); + 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 sink) : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink); + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + } + + private sealed class CapturingLogger(List sink) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (sink) sink.Add(formatter(state, exception)); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs new file mode 100644 index 00000000..f16ffb3c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// 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. +/// +[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(); + 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 sink) : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink); + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + } + + private sealed class CapturingLogger(List sink) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (sink) sink.Add(formatter(state, exception)); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs index 3e171d59..e338494b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs @@ -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\""); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs index 5c22a9a1..e77da8ad 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -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\""); + } }