using System.Collections.Generic; using System.Linq; 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; public sealed class OpcUaClientDriverPageFormSerializationTests { private static readonly JsonSerializerOptions _opts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, }; [Fact] public void RoundTrip_PreservesKnownFields() { var original = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://plc.internal:4840", ApplicationUri = "urn:plc:OtOpcUa:GatewayClient", SessionName = "MySession", SecurityMode = OpcUaSecurityMode.SignAndEncrypt, SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256, AuthType = OpcUaAuthType.Username, Username = "operator", Password = "s3cr3t", PerEndpointConnectTimeout = TimeSpan.FromSeconds(5), Timeout = TimeSpan.FromSeconds(20), SessionTimeout = TimeSpan.FromSeconds(180), KeepAliveInterval = TimeSpan.FromSeconds(10), ReconnectPeriod = TimeSpan.FromSeconds(15), AutoAcceptCertificates = true, BrowseRoot = "i=85", MaxDiscoveredNodes = 5000, MaxBrowseDepth = 6, TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform, ProbeTimeoutSeconds = 20, }; var json = JsonSerializer.Serialize(original, _opts); var back = JsonSerializer.Deserialize(json, _opts); back.ShouldNotBeNull(); back.EndpointUrl.ShouldBe("opc.tcp://plc.internal:4840"); back.ApplicationUri.ShouldBe("urn:plc:OtOpcUa:GatewayClient"); back.SessionName.ShouldBe("MySession"); back.SecurityMode.ShouldBe(OpcUaSecurityMode.SignAndEncrypt); back.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.Basic256Sha256); back.AuthType.ShouldBe(OpcUaAuthType.Username); back.Username.ShouldBe("operator"); back.Password.ShouldBe("s3cr3t"); back.PerEndpointConnectTimeout.ShouldBe(TimeSpan.FromSeconds(5)); back.Timeout.ShouldBe(TimeSpan.FromSeconds(20)); back.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(180)); back.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(10)); back.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(15)); back.AutoAcceptCertificates.ShouldBeTrue(); back.BrowseRoot.ShouldBe("i=85"); back.MaxDiscoveredNodes.ShouldBe(5000); back.MaxBrowseDepth.ShouldBe(6); back.TargetNamespaceKind.ShouldBe(OpcUaTargetNamespaceKind.SystemPlatform); back.ProbeTimeoutSeconds.ShouldBe(20); } [Fact] public void Deserialize_DropsUnknownFields() { var jsonWithExtra = """{"unknownField":"old-value","probeTimeoutSeconds":20}"""; var optsWithSkip = new JsonSerializerOptions(_opts) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, }; var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip); back.ShouldNotBeNull(); back.ProbeTimeoutSeconds.ShouldBe(20); } [Fact] public void FormModel_RoundTrip_PreservesAllFields() { // Construct options with non-default values for every editable property plus a // non-empty UnsMappingTable (read-only in the form, round-tripped via the original // record). EndpointUrls is now edited via the CollectionEditor on the page and is // threaded into ToRecord explicitly; see EndpointUrls_ListRoundTrip_PreservesOrder. 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(endpointUrls); 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); } [Fact] public void EndpointUrlRow_FromUrl_ToUrl_Trims() { var row = OpcUaClientDriverPage.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 "); row.Url.ShouldBe(" opc.tcp://plc:4840 "); row.ToUrl().ShouldBe("opc.tcp://plc:4840"); } [Fact] public void EndpointUrlRow_ValidateRow_RejectsBlank() { var all = new List(); var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " " }; var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null); error.ShouldBe("URL is required."); } [Fact] public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme() { var all = new List(); var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "http://plc:4840" }; var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null); error.ShouldBe("Endpoint URL must start with opc.tcp://"); } [Fact] public void EndpointUrlRow_ValidateRow_RejectsDuplicate() { var all = new List { new() { Url = "opc.tcp://primary:4840" }, new() { Url = "opc.tcp://backup:4840" }, }; // Adding a new row (editIndex null) duplicating the first — case-insensitive, whitespace-insensitive. var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " }; var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null); error.ShouldNotBeNull(); error.ShouldContain("Duplicate endpoint"); } [Fact] public void EndpointUrlRow_ValidateRow_AllowsEditingRowInPlace() { var all = new List { new() { Url = "opc.tcp://primary:4840" }, new() { Url = "opc.tcp://backup:4840" }, }; // Editing index 0 and keeping the same URL must not flag itself as a duplicate. var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "opc.tcp://primary:4840" }; var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, 0); error.ShouldBeNull(); } [Fact] public void EndpointUrls_ListRoundTrip_PreservesOrder() { // The page holds endpoints as a List; loading from EndpointUrls and // converting back must preserve order (the failover list is ordered, primary first). var endpointUrls = new List { "opc.tcp://primary:4840", "opc.tcp://secondary:4840", "opc.tcp://tertiary:4840" }; var rows = endpointUrls .Select(OpcUaClientDriverPage.EndpointUrlRow.FromUrl) .ToList(); var roundTripped = rows.Select(r => r.ToUrl()).ToList(); roundTripped.Count.ShouldBe(3); roundTripped[0].ShouldBe("opc.tcp://primary:4840"); roundTripped[1].ShouldBe("opc.tcp://secondary:4840"); roundTripped[2].ShouldBe("opc.tcp://tertiary:4840"); var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions()); var result = form.ToRecord(roundTripped); result.EndpointUrls.Count.ShouldBe(3); result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840"); result.EndpointUrls[2].ShouldBe("opc.tcp://tertiary:4840"); } }