diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor index 0b32409d..df208cf3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor @@ -300,6 +300,9 @@ else // Tags JSON view (read-only) public string? TagsJson { get; set; } + // Preserved originals (round-tripped unchanged from original options) + private IReadOnlyList _tags = []; + // Common public string? ResilienceConfig { get; set; } public byte[] RowVersion { get; set; } = []; @@ -326,6 +329,7 @@ else ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, TagsJson = tagsJson, + _tags = o.Tags, }; } @@ -344,8 +348,7 @@ else Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), }, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, - // Tags preserved from original JSON; this form does not edit the tag list. - Tags = [], + Tags = _tags, }; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs index 1e21c422..365a3e6f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -80,4 +81,32 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests back.ProbeTimeoutSeconds.ShouldBe(20); back.PipeName.ShouldBe("otopcua-historian"); } + + [Fact] + public void FormModel_RoundTrip_PreservesAllFields() + { + // Construct a record with non-default values for every property and verify + // that WonderwareHistorianClientFormModel.FromRecord → ToRecord is lossless. + var original = new WonderwareHistorianClientOptions( + PipeName: "otopcua-historian-prod", + SharedSecret: "sup3rs3cr3t", + PeerName: "OtOpcUa-Redundant", + ConnectTimeout: TimeSpan.FromSeconds(18), + CallTimeout: TimeSpan.FromSeconds(45)) + { + ProbeTimeoutSeconds = 30, + }; + + var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original); + var result = form.ToRecord(); + + result.PipeName.ShouldBe("otopcua-historian-prod"); + result.SharedSecret.ShouldBe("sup3rs3cr3t"); + result.PeerName.ShouldBe("OtOpcUa-Redundant"); + result.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18)); + result.CallTimeout.ShouldBe(TimeSpan.FromSeconds(45)); + result.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18)); + result.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(45)); + result.ProbeTimeoutSeconds.ShouldBe(30); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs index 2e325812..12f61c46 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs @@ -1,7 +1,9 @@ +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -79,4 +81,76 @@ public sealed class OpcUaClientDriverPageFormSerializationTests back.ShouldNotBeNull(); back.ProbeTimeoutSeconds.ShouldBe(20); } + + [Fact] + public void FormModel_RoundTrip_PreservesAllFields() + { + // Construct options with non-default values for every editable property plus + // non-empty EndpointUrls and UnsMappingTable — both are "read-only" in the form + // but must survive the FormModel translation unchanged. + var endpointUrls = new List { "opc.tcp://primary:4840", "opc.tcp://backup:4840" }; + var unsMappingTable = new Dictionary + { + ["Line1/"] = "Site/Area1/Line1", + ["Line2/"] = "Site/Area1/Line2", + }; + + var original = new OpcUaClientDriverOptions + { + EndpointUrl = "opc.tcp://fallback:4840", + EndpointUrls = endpointUrls, + ApplicationUri = "urn:test:OtOpcUa:GatewayClient", + SessionName = "TestSession", + SecurityMode = OpcUaSecurityMode.SignAndEncrypt, + SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256, + AuthType = OpcUaAuthType.Username, + Username = "opcuser", + Password = "p@ssw0rd", + UserCertificatePath = @"C:\certs\user.pfx", + UserCertificatePassword = "certpass", + PerEndpointConnectTimeout = TimeSpan.FromSeconds(4), + Timeout = TimeSpan.FromSeconds(12), + SessionTimeout = TimeSpan.FromSeconds(150), + KeepAliveInterval = TimeSpan.FromSeconds(7), + ReconnectPeriod = TimeSpan.FromSeconds(8), + AutoAcceptCertificates = true, + BrowseRoot = "i=85", + MaxDiscoveredNodes = 3000, + MaxBrowseDepth = 5, + TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform, + UnsMappingTable = unsMappingTable, + ProbeTimeoutSeconds = 25, + }; + + var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(original); + var result = form.ToRecord(); + + result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840"); + result.EndpointUrls.Count.ShouldBe(2); + result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840"); + result.EndpointUrls[1].ShouldBe("opc.tcp://backup:4840"); + result.ApplicationUri.ShouldBe("urn:test:OtOpcUa:GatewayClient"); + result.SessionName.ShouldBe("TestSession"); + result.SecurityMode.ShouldBe(OpcUaSecurityMode.SignAndEncrypt); + result.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.Basic256Sha256); + result.AuthType.ShouldBe(OpcUaAuthType.Username); + result.Username.ShouldBe("opcuser"); + result.Password.ShouldBe("p@ssw0rd"); + result.UserCertificatePath.ShouldBe(@"C:\certs\user.pfx"); + result.UserCertificatePassword.ShouldBe("certpass"); + result.PerEndpointConnectTimeout.ShouldBe(TimeSpan.FromSeconds(4)); + result.Timeout.ShouldBe(TimeSpan.FromSeconds(12)); + result.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(150)); + result.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(7)); + result.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(8)); + result.AutoAcceptCertificates.ShouldBeTrue(); + result.BrowseRoot.ShouldBe("i=85"); + result.MaxDiscoveredNodes.ShouldBe(3000); + result.MaxBrowseDepth.ShouldBe(5); + result.TargetNamespaceKind.ShouldBe(OpcUaTargetNamespaceKind.SystemPlatform); + result.UnsMappingTable.Count.ShouldBe(2); + result.UnsMappingTable["Line1/"].ShouldBe("Site/Area1/Line1"); + result.UnsMappingTable["Line2/"].ShouldBe("Site/Area1/Line2"); + result.ProbeTimeoutSeconds.ShouldBe(25); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs index 9c48be8d..4f446670 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs @@ -69,6 +69,12 @@ public sealed class S7DriverPageFormSerializationTests [Fact] public void FormModel_RoundTrip_PreservesEditableFields() { + var tags = new[] + { + new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true), + new S7TagDefinition("Status", "DB1.DBW4", S7DataType.Int16, Writable: false), + }; + var opts = new S7DriverOptions { Host = "192.168.1.50", @@ -84,6 +90,7 @@ public sealed class S7DriverPageFormSerializationTests Timeout = TimeSpan.FromSeconds(4), }, ProbeTimeoutSeconds = 20, + Tags = tags, }; var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers @@ -100,5 +107,14 @@ public sealed class S7DriverPageFormSerializationTests roundTripped.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(8)); roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4)); roundTripped.ProbeTimeoutSeconds.ShouldBe(20); + + // Tags must survive the FormModel round-trip unchanged (regression guard for the + // Tags = [] data-loss bug fixed in this PR). + roundTripped.Tags.Count.ShouldBe(2); + roundTripped.Tags[0].Name.ShouldBe("Speed"); + roundTripped.Tags[0].Address.ShouldBe("DB1.DBD0"); + roundTripped.Tags[0].DataType.ShouldBe(S7DataType.Float32); + roundTripped.Tags[1].Name.ShouldBe("Status"); + roundTripped.Tags[1].Writable.ShouldBeFalse(); } }