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.Shared.Drivers.Forms; 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, 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.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. 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 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, ProbeTimeoutSeconds = 25, }; var form = OpcUaClientDriverForm.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.ProbeTimeoutSeconds.ShouldBe(25); } /// /// #273/#275/#277/#278: the typed form rewrites the whole record on save, so a key it does not model /// is silently dropped (#576). Every new option must survive FromRecord -> ToRecord. /// [Fact] public void FormModel_RoundTrip_PreservesTuningLimitsRevocationAndDiscovery() { var original = new OpcUaClientDriverOptions { SubscriptionPublishingInterval = TimeSpan.FromMilliseconds(2500), SubscriptionKeepAliveCount = 20, SubscriptionLifetimeCount = 600, SubscriptionMaxNotificationsPerPublish = 500, SubscriptionPriority = 9, CertificateValidation = new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = true, IssuerStorePath = "/pki/issuers", }, DiscoveryUrl = "opc.tcp://lds:4840", DiscoveryServerUri = "urn:plant:line1", }; var back = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original).ToRecord([]); back.SubscriptionPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(2500)); back.SubscriptionKeepAliveCount.ShouldBe(20u); back.SubscriptionLifetimeCount.ShouldBe(600u); back.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u); back.SubscriptionPriority.ShouldBe((byte)9); back.DiscoveryServerUri.ShouldBe("urn:plant:line1"); back.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue(); back.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers"); back.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840"); // Defaults round-trip as defaults (blank override stays null, not 0 ms). var def = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions()).ToRecord([]); def.SubscriptionPublishingInterval.ShouldBeNull(); def.SubscriptionKeepAliveCount.ShouldBe(10u); def.SubscriptionLifetimeCount.ShouldBe(1000u); def.DiscoveryUrl.ShouldBeNull(); } /// /// #648: stored DriverConfig JSON from before targetNamespaceKind/unsMappingTable were removed /// must still load into the form (the form's options use UnmappedMemberHandling.Skip) and the legacy /// keys must NOT be re-emitted on save. /// [Fact] public void LegacyNamespaceKeys_LoadIntoForm_AndAreNotReEmitted() { var formOpts = new JsonSerializerOptions(_opts) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, Converters = { new JsonStringEnumConverter() }, }; const string legacy = """{"endpointUrl":"opc.tcp://plc:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"Line1/":"Site/Area1/Line1"}}"""; var opts = JsonSerializer.Deserialize(legacy, formOpts); opts.ShouldNotBeNull(); opts.EndpointUrl.ShouldBe("opc.tcp://plc:4840"); var saved = JsonSerializer.Serialize( OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(opts).ToRecord([]), formOpts); saved.ShouldNotContain("targetNamespaceKind", Case.Insensitive); saved.ShouldNotContain("unsMappingTable", Case.Insensitive); } [Fact] public void EndpointUrlRow_FromUrl_ToUrl_Trims() { var row = OpcUaClientDriverForm.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 OpcUaClientDriverForm.EndpointUrlRow { Url = " " }; var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null); error.ShouldBe("URL is required."); } [Fact] public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme() { var all = new List(); var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = "http://plc:4840" }; var error = OpcUaClientDriverForm.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 OpcUaClientDriverForm.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " }; var error = OpcUaClientDriverForm.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 OpcUaClientDriverForm.EndpointUrlRow { Url = "opc.tcp://primary:4840" }; var error = OpcUaClientDriverForm.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(OpcUaClientDriverForm.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 = OpcUaClientDriverForm.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"); } }