Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCurationTests.cs
Joseph Doherty 02d1c85190 Auto: opcuaclient-7 — selective import + namespace remap
Adds OpcUaClientCurationOptions on OpcUaClientDriverOptions.Curation with:
- IncludePaths/ExcludePaths globs (* and ? only) matched against the
  slash-joined BrowsePath segments collected during BrowseRecursiveAsync.
  Empty Include = include all so existing deployments are unaffected;
  Exclude wins over Include. Pruned folders are skipped wholesale, so
  descendants don't reach the wire.
- NamespaceRemap (URI -> URI) applied to DriverAttributeInfo.FullName when
  registering variables. Index-0 / standard nodes round-trip unchanged;
  remapped nodes serialise via ExpandedNodeId so downstream clients see
  the local-side URI.
- RootAlias replaces the hard-coded "Remote" folder name at the top of
  the mirrored tree.

Closes #279
2026-04-25 20:20:47 -04:00

156 lines
6.0 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for the curation surface added in PR-7 (selective import + namespace
/// remap + root-alias). Focused on the pure-function helpers
/// (<see cref="OpcUaClientDriver.CompileGlobs"/>,
/// <see cref="OpcUaClientDriver.ShouldInclude"/>,
/// <see cref="OpcUaClientDriver.BuildRemappedFullName"/>) so the assertions don't need a
/// live upstream server. End-to-end browse + filter coverage lands in the integration
/// tests against opc-plc.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientCurationTests
{
[Fact]
public void CurationOptions_default_is_empty_so_existing_behaviour_is_preserved()
{
var opts = new OpcUaClientDriverOptions();
opts.Curation.IncludePaths.ShouldBeNull();
opts.Curation.ExcludePaths.ShouldBeNull();
opts.Curation.NamespaceRemap.ShouldBeNull();
opts.Curation.RootAlias.ShouldBeNull();
}
[Fact]
public void ShouldInclude_empty_include_means_include_all()
{
OpcUaClientDriver.ShouldInclude("Server/Status", include: null, exclude: null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("anything", include: null, exclude: null).ShouldBeTrue();
}
[Fact]
public void ShouldInclude_star_glob_matches_any_segment_chars()
{
var include = OpcUaClientDriver.CompileGlobs(new[] { "Plant/*" });
OpcUaClientDriver.ShouldInclude("Plant/Tank1", include, null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("Plant/Tank1/Level", include, null).ShouldBeTrue("'*' matches across slashes — single-glob simple semantics");
OpcUaClientDriver.ShouldInclude("Other/Tank1", include, null).ShouldBeFalse();
}
[Fact]
public void ShouldInclude_question_mark_matches_exactly_one_char()
{
var include = OpcUaClientDriver.CompileGlobs(new[] { "Tank?" });
OpcUaClientDriver.ShouldInclude("Tank1", include, null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("Tank12", include, null).ShouldBeFalse();
OpcUaClientDriver.ShouldInclude("Tank", include, null).ShouldBeFalse();
}
[Fact]
public void ShouldInclude_exclude_wins_over_include()
{
var include = OpcUaClientDriver.CompileGlobs(new[] { "Plant/*" });
var exclude = OpcUaClientDriver.CompileGlobs(new[] { "Plant/Diagnostics*" });
OpcUaClientDriver.ShouldInclude("Plant/Tank1", include, exclude).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("Plant/Diagnostics", include, exclude).ShouldBeFalse();
OpcUaClientDriver.ShouldInclude("Plant/DiagnosticsTree/Sub", include, exclude).ShouldBeFalse();
}
[Fact]
public void ShouldInclude_multiple_includes_are_OR()
{
var include = OpcUaClientDriver.CompileGlobs(new[] { "PlantA/*", "PlantB/*" });
OpcUaClientDriver.ShouldInclude("PlantA/Tank", include, null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("PlantB/Tank", include, null).ShouldBeTrue();
OpcUaClientDriver.ShouldInclude("PlantC/Tank", include, null).ShouldBeFalse();
}
[Fact]
public void CompileGlobs_returns_null_for_empty_or_null_input()
{
OpcUaClientDriver.CompileGlobs(null).ShouldBeNull();
OpcUaClientDriver.CompileGlobs(System.Array.Empty<string>()).ShouldBeNull();
OpcUaClientDriver.CompileGlobs(new[] { string.Empty }).ShouldBeNull();
}
[Fact]
public void CompileGlobs_escapes_regex_metacharacters_in_literal_segments()
{
// A bare "." in the glob must NOT match arbitrary chars — it's escaped to a literal dot.
var rx = OpcUaClientDriver.CompileGlobs(new[] { "App.exe" });
rx.ShouldNotBeNull();
rx!.IsMatch("App.exe").ShouldBeTrue();
rx.IsMatch("AppXexe").ShouldBeFalse();
}
[Fact]
public void BuildRemappedFullName_returns_default_string_when_remap_is_empty()
{
var node = new NodeId("Tank/Level", 2);
OpcUaClientDriver.BuildRemappedFullName(node, table: null, remap: null)
.ShouldBe(node.ToString());
}
[Fact]
public void BuildRemappedFullName_passes_through_namespace_zero_unchanged()
{
// Standard OPC UA nodes (ns=0) bypass remap so the base namespace round-trips.
var node = new NodeId(2253u); // Server
var remap = new Dictionary<string, string> { ["http://opcfoundation.org/UA/"] = "urn:local:base" };
OpcUaClientDriver.BuildRemappedFullName(node, table: null, remap: remap)
.ShouldBe(node.ToString());
}
[Fact]
public void BuildRemappedFullName_translates_upstream_uri_when_remap_hits()
{
var table = new NamespaceTable();
table.Append("urn:upstream:plc-vendor"); // index 1
var node = new NodeId("Tank/Level", 1);
var remap = new Dictionary<string, string>
{
["urn:upstream:plc-vendor"] = "urn:local:plant",
};
var result = OpcUaClientDriver.BuildRemappedFullName(node, table, remap);
result.ShouldContain("urn:local:plant", Case.Sensitive);
result.ShouldNotContain("urn:upstream:plc-vendor");
}
[Fact]
public void BuildRemappedFullName_no_op_when_upstream_uri_not_in_remap_table()
{
var table = new NamespaceTable();
table.Append("urn:upstream:other"); // index 1
var node = new NodeId("Some/Item", 1);
var remap = new Dictionary<string, string>
{
["urn:not-matching"] = "urn:local:something",
};
OpcUaClientDriver.BuildRemappedFullName(node, table, remap).ShouldBe(node.ToString());
}
[Fact]
public void RootAlias_default_null_means_Remote()
{
var opts = new OpcUaClientDriverOptions();
opts.Curation.RootAlias.ShouldBeNull();
}
[Fact]
public void RootAlias_can_be_set_via_Curation_record()
{
var opts = new OpcUaClientDriverOptions
{
Curation = new OpcUaClientCurationOptions(RootAlias: "Plant"),
};
opts.Curation.RootAlias.ShouldBe("Plant");
}
}