using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Regression coverage for driver-specs.md §8 namespace handling — the /// TargetNamespaceKind startup enforcement and the server-stable NodeId encoding /// that produces (findings Driver.OpcUaClient-004). /// [Trait("Category", "Unit")] public sealed class OpcUaClientNamespaceTests { // ---- Driver.OpcUaClient-004: TargetNamespaceKind startup enforcement ---- /// Verifies that Equipment namespace kind without mapping table is rejected. [Fact] public void ValidateNamespaceKind_Equipment_without_mapping_table_is_rejected() { // §8: an Equipment-kind instance gateways raw equipment data and MUST carry a // config-driven UNS mapping table — remote nodes don't conform to UNS by default. var opts = new OpcUaClientDriverOptions { TargetNamespaceKind = OpcUaTargetNamespaceKind.Equipment }; Should.Throw(() => OpcUaClientDriver.ValidateNamespaceKind(opts)) .Message.ShouldContain("UnsMappingTable"); } /// Verifies that Equipment namespace kind with mapping table passes. [Fact] public void ValidateNamespaceKind_Equipment_with_mapping_table_passes() { var opts = new OpcUaClientDriverOptions { TargetNamespaceKind = OpcUaTargetNamespaceKind.Equipment, UnsMappingTable = new Dictionary { ["Line1"] = "Site/AreaA/Line1" }, }; Should.NotThrow(() => OpcUaClientDriver.ValidateNamespaceKind(opts)); } /// Verifies that SystemPlatform namespace kind with mapping table is rejected. [Fact] public void ValidateNamespaceKind_SystemPlatform_with_mapping_table_is_rejected() { // §8: processed data preserves its own hierarchy — a UNS mapping table is ambiguous. var opts = new OpcUaClientDriverOptions { TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform, UnsMappingTable = new Dictionary { ["x"] = "y" }, }; Should.Throw(() => OpcUaClientDriver.ValidateNamespaceKind(opts)) .Message.ShouldContain("SystemPlatform"); } /// Verifies that SystemPlatform namespace kind without mapping table passes. [Fact] public void ValidateNamespaceKind_SystemPlatform_without_mapping_table_passes() { var opts = new OpcUaClientDriverOptions { TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform }; Should.NotThrow(() => OpcUaClientDriver.ValidateNamespaceKind(opts)); } /// Verifies that the default target namespace kind is Equipment. [Fact] public void Default_TargetNamespaceKind_is_Equipment() { // §8 comparison table: OPC UA Client default lane is Equipment. new OpcUaClientDriverOptions().TargetNamespaceKind.ShouldBe(OpcUaTargetNamespaceKind.Equipment); } // ---- Driver.OpcUaClient-004: server-stable NodeId encoding ---- /// Verifies that NamespaceMap.FromSession rejects null session. [Fact] public void NamespaceMap_FromSession_rejects_null_session() => Should.Throw(() => NamespaceMap.FromSession(null!)); /// Verifies that namespace 0 NodeId keeps compact form. [Fact] public void NamespaceMap_namespace0_NodeId_keeps_compact_form() { // Namespace 0 is fixed by the OPC UA spec — it never moves, so the compact i=… form // is already server-stable and must not be rewritten to nsu=… var map = TestMap("http://opcfoundation.org/UA/", "urn:remote:server"); var coreNode = new NodeId(2253u); // i=2253, Server object, namespace 0 map.ToStableReference(coreNode).ShouldBe(coreNode.ToString()); map.ToStableReference(coreNode).ShouldNotContain("nsu="); } /// Verifies that nonzero namespace NodeId is encoded with URI, not index. [Fact] public void NamespaceMap_nonzero_namespace_NodeId_is_encoded_with_uri_not_index() { // A ns=1 reference is session-relative; if the remote reorders its table the index // points at the wrong namespace. ToStableReference must embed the URI instead. var map = TestMap("http://opcfoundation.org/UA/", "urn:remote:server"); var node = new NodeId("Temperature", 1); var stable = map.ToStableReference(node); stable.ShouldContain("nsu=urn:remote:server"); stable.ShouldContain("s=Temperature"); stable.ShouldNotStartWith("ns=1"); } /// Verifies that unknown namespace index falls back to raw form. [Fact] public void NamespaceMap_unknown_namespace_index_falls_back_to_raw_form() { // An index past the captured table can't be URI-encoded; fall back rather than throw — // the read/write path surfaces BadNodeIdInvalid if it truly can't resolve. var map = TestMap("http://opcfoundation.org/UA/"); var node = new NodeId("Orphan", 5); Should.NotThrow(() => map.ToStableReference(node)); } /// Verifies that namespace index and URI lookups are bidirectional. [Fact] public void NamespaceMap_index_and_uri_lookups_are_bidirectional() { var map = TestMap("http://opcfoundation.org/UA/", "urn:remote:server", "urn:remote:vendor"); map.Count.ShouldBe(3); map.UriForIndex(1).ShouldBe("urn:remote:server"); map.IndexForUri("urn:remote:vendor").ShouldBe((ushort)2); map.IndexForUri("urn:not:present").ShouldBeNull(); map.UriForIndex(99).ShouldBeNull(); } /// Verifies that NamespaceMap.TryResolve rejects empty and null input. [Fact] public void NamespaceMap_TryResolve_rejects_empty_and_null_input() { NamespaceMap.TryResolve(null!, "ns=1;s=X", out _).ShouldBeFalse(); } // ---- v3: RawPath → upstream node id resolution table ---- /// Verifies a RawPath resolves to the upstream node id authored in its TagConfig.nodeId. [Fact] public void RawPath_resolves_to_upstream_nodeId_from_tagconfig() { var map = RawMap( new RawTagEntry("Plant/Line1/Temp", """{"nodeId":"nsu=urn:remote:server;s=Temperature"}""", WriteIdempotent: false), new RawTagEntry("Plant/Line1/Speed", """{"nodeId":"ns=2;s=Speed"}""", WriteIdempotent: true)); map.RawTagCount.ShouldBe(2); map.TryResolve("Plant/Line1/Temp", out var temp).ShouldBeTrue(); temp.ShouldBe("nsu=urn:remote:server;s=Temperature"); map.TryResolve("Plant/Line1/Speed", out var speed).ShouldBeTrue(); speed.ShouldBe("ns=2;s=Speed"); } /// Verifies an unknown RawPath and a blank RawPath both fail resolution. [Fact] public void RawPath_unknown_or_blank_fails_resolution() { var map = RawMap(new RawTagEntry("Plant/Line1/Temp", """{"nodeId":"ns=2;s=Temperature"}""", WriteIdempotent: false)); map.TryResolve("Plant/Line1/Missing", out var missing).ShouldBeFalse(); missing.ShouldBe(string.Empty); map.TryResolve(" ", out _).ShouldBeFalse(); } /// Verifies WriteIdempotent is threaded onto the table from the RawTagEntry, not the blob. [Fact] public void RawPath_binding_threads_writeidempotent_flag() { var map = RawMap( new RawTagEntry("Plant/A", """{"nodeId":"ns=2;s=A"}""", WriteIdempotent: true), new RawTagEntry("Plant/B", """{"nodeId":"ns=2;s=B"}""", WriteIdempotent: false)); map.TryGetBinding("Plant/A", out var a).ShouldBeTrue(); a.NodeId.ShouldBe("ns=2;s=A"); a.WriteIdempotent.ShouldBeTrue(); map.TryGetBinding("Plant/B", out var b).ShouldBeTrue(); b.WriteIdempotent.ShouldBeFalse(); } /// Verifies an entry whose TagConfig lacks a usable nodeId is skipped (not registered). [Fact] public void RawPath_entry_without_nodeId_is_skipped() { var map = RawMap( new RawTagEntry("Plant/Good", """{"nodeId":"ns=2;s=Good"}""", WriteIdempotent: false), new RawTagEntry("Plant/NoNode", """{"other":"value"}""", WriteIdempotent: false), new RawTagEntry("Plant/Blank", """{"nodeId":" "}""", WriteIdempotent: false), new RawTagEntry("Plant/Garbage", "not json", WriteIdempotent: false)); map.RawTagCount.ShouldBe(1); map.TryResolve("Plant/Good", out _).ShouldBeTrue(); map.TryResolve("Plant/NoNode", out _).ShouldBeFalse(); map.TryResolve("Plant/Blank", out _).ShouldBeFalse(); map.TryResolve("Plant/Garbage", out _).ShouldBeFalse(); } /// Verifies a PascalCase "NodeId" key is accepted (legacy tolerance). [Fact] public void RawPath_accepts_legacy_pascalcase_nodeid_key() { var map = RawMap(new RawTagEntry("Plant/Legacy", """{"NodeId":"ns=2;s=Legacy"}""", WriteIdempotent: false)); map.TryResolve("Plant/Legacy", out var nid).ShouldBeTrue(); nid.ShouldBe("ns=2;s=Legacy"); } /// Verifies a map built without raw tags has an empty resolution table. [Fact] public void Map_without_rawtags_has_empty_resolution_table() { var map = TestMap("http://opcfoundation.org/UA/", "urn:remote:server"); map.RawTagCount.ShouldBe(0); map.TryResolve("anything", out _).ShouldBeFalse(); } /// /// Build a directly from a URI table, mirroring what /// snapshots — lets the encoding be tested /// without standing up a live ISession. /// private static NamespaceMap TestMap(params string[] uris) { var table = new NamespaceTable(); // Index 0 (the OPC UA core namespace) is seeded by NamespaceTable's ctor; append the // rest. Skip uris[0] when it is the core namespace to avoid a duplicate. for (var i = 0; i < uris.Length; i++) { if (i == 0 && uris[i] == "http://opcfoundation.org/UA/") continue; table.Append(uris[i]); } return NamespaceMap.FromTable(table); } /// Build a carrying only the v3 RawPath table (core URI table). private static NamespaceMap RawMap(params RawTagEntry[] rawTags) => NamespaceMap.FromTable(new NamespaceTable(), rawTags); }