v3 B1-WP2: TagConfigIntent sheds FullName identity; RawPaths tests; nodeId key normalization

This commit is contained in:
Joseph Doherty
2026-07-15 18:58:57 -04:00
parent cb720bb8c3
commit d4dab75323
6 changed files with 271 additions and 176 deletions
@@ -0,0 +1,207 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// The v3 identity authority (<see cref="RawPaths"/>): segment validation, slash-joined build,
/// combine/split/leaf/parent, and the case-sensitive ordinal comparer. Every identity seam in v3
/// keys on the strings this helper produces, so the separator + segment charset contract is pinned
/// here in exactly one place.
/// </summary>
public sealed class RawPathsTests
{
// ---- constants ----
[Fact]
public void Separator_is_forward_slash()
{
RawPaths.Separator.ShouldBe('/');
RawPaths.SeparatorString.ShouldBe("/");
}
[Fact]
public void Comparer_is_ordinal_case_sensitive()
{
RawPaths.Comparer.ShouldBe(StringComparer.Ordinal);
RawPaths.Comparer.Equals("Line/Tag", "line/tag").ShouldBeFalse();
RawPaths.Comparer.Equals("Line/Tag", "Line/Tag").ShouldBeTrue();
}
// ---- ValidateSegment / IsValidSegment ----
[Theory]
[InlineData("Modbus")]
[InlineData("Device_001")]
[InlineData("Tag.Name")] // dots are legal inside a segment
[InlineData("a")]
[InlineData("Has Space")] // interior whitespace is legal; only leading/trailing is not
public void ValidateSegment_accepts_legal_segments(string segment)
{
RawPaths.ValidateSegment(segment).ShouldBeNull();
RawPaths.IsValidSegment(segment).ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void ValidateSegment_rejects_empty(string? segment)
{
RawPaths.ValidateSegment(segment).ShouldNotBeNull();
RawPaths.IsValidSegment(segment).ShouldBeFalse();
}
[Theory]
[InlineData("a/b")]
[InlineData("/leading")]
[InlineData("trailing/")]
public void ValidateSegment_rejects_separator(string segment)
{
RawPaths.ValidateSegment(segment).ShouldNotBeNull();
RawPaths.ValidateSegment(segment)!.ShouldContain("/");
RawPaths.IsValidSegment(segment).ShouldBeFalse();
}
[Theory]
[InlineData(" leading")]
[InlineData("trailing ")]
[InlineData("\ttab")]
[InlineData("nl\n")]
public void ValidateSegment_rejects_edge_whitespace(string segment)
{
RawPaths.ValidateSegment(segment).ShouldNotBeNull();
RawPaths.IsValidSegment(segment).ShouldBeFalse();
}
// ---- Build ----
[Fact]
public void Build_joins_multiple_segments_with_separator()
{
RawPaths.Build("Folder", "Modbus", "Dev", "Group", "Tag")
.ShouldBe("Folder/Modbus/Dev/Group/Tag");
}
[Fact]
public void Build_single_segment_is_the_segment()
{
RawPaths.Build("Solo").ShouldBe("Solo");
}
[Fact]
public void Build_enumerable_overload_joins()
{
RawPaths.Build(new List<string> { "A", "B" }).ShouldBe("A/B");
}
[Fact]
public void Build_empty_list_throws()
{
Should.Throw<ArgumentException>(() => RawPaths.Build(Array.Empty<string>()));
}
[Fact]
public void Build_null_throws()
{
Should.Throw<ArgumentNullException>(() => RawPaths.Build((IEnumerable<string>)null!));
}
[Fact]
public void Build_invalid_segment_throws_naming_the_index()
{
var ex = Should.Throw<ArgumentException>(() => RawPaths.Build("ok", "bad/seg", "also-ok"));
ex.Message.ShouldContain("index 1");
}
// ---- Combine ----
[Fact]
public void Combine_blank_parent_yields_child_alone()
{
RawPaths.Combine(null, "Root").ShouldBe("Root");
RawPaths.Combine("", "Root").ShouldBe("Root");
}
[Fact]
public void Combine_appends_child_under_parent()
{
RawPaths.Combine("A/B", "C").ShouldBe("A/B/C");
}
[Fact]
public void Combine_invalid_child_throws()
{
Should.Throw<ArgumentException>(() => RawPaths.Combine("A/B", "c/d"));
Should.Throw<ArgumentException>(() => RawPaths.Combine("A/B", ""));
}
// ---- Split ----
[Fact]
public void Split_returns_ordered_segments()
{
RawPaths.Split("A/B/C").ShouldBe(new[] { "A", "B", "C" });
}
[Fact]
public void Split_single_segment_is_one_element()
{
RawPaths.Split("Solo").ShouldBe(new[] { "Solo" });
}
// ---- Leaf ----
[Fact]
public void Leaf_of_multi_segment_is_last()
{
RawPaths.Leaf("A/B/C").ShouldBe("C");
}
[Fact]
public void Leaf_of_single_segment_is_itself()
{
RawPaths.Leaf("Solo").ShouldBe("Solo");
}
// ---- TryParent ----
[Fact]
public void TryParent_of_multi_segment_returns_parent()
{
RawPaths.TryParent("A/B/C", out var parent).ShouldBeTrue();
parent.ShouldBe("A/B");
}
[Fact]
public void TryParent_of_two_segment_returns_root()
{
RawPaths.TryParent("A/B", out var parent).ShouldBeTrue();
parent.ShouldBe("A");
}
[Fact]
public void TryParent_of_single_segment_returns_false()
{
RawPaths.TryParent("Solo", out var parent).ShouldBeFalse();
parent.ShouldBe(string.Empty);
}
// ---- round trips ----
[Fact]
public void Build_then_Split_round_trips()
{
var segs = new[] { "Folder", "Sub", "Modbus", "Dev", "Tag" };
RawPaths.Split(RawPaths.Build(segs)).ShouldBe(segs);
}
[Fact]
public void Combine_then_Leaf_and_Parent_are_consistent()
{
var path = RawPaths.Combine("A/B", "C");
RawPaths.Leaf(path).ShouldBe("C");
RawPaths.TryParent(path, out var parent).ShouldBeTrue();
parent.ShouldBe("A/B");
}
}
@@ -6,50 +6,49 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// The single byte-parity authority for the cross-driver TagConfig platform intents (R2-11, 01/C-1).
/// Ports the three retired <c>OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests</c> tables and
/// adds the <see cref="TagConfigIntent.FullName"/> / <see cref="TagConfigIntent.ExplicitFullName"/>
/// distinction plus the never-throws property.
/// Ports the three retired <c>OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests</c> tables plus
/// the never-throws property. Under the v3 identity contract <c>TagConfigIntent</c> no longer derives
/// any FullName/identity — only the platform intents (alarm / historize / array) are parsed here.
/// </summary>
public sealed class TagConfigIntentTests
{
// ---- FullName / ExplicitFullName ----
[Theory]
[InlineData("{\"FullName\":\"X.Y\"}", "X.Y", "X.Y")]
[InlineData("{\"FullName\":\" \"}", " ", " ")] // whitespace string returned verbatim (not trimmed)
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", "A.B", "A.B")]
// fallbacks: FullName = raw blob, ExplicitFullName = null
[InlineData("{\"region\":\"HoldingRegisters\"}", "{\"region\":\"HoldingRegisters\"}", null)]
[InlineData("{\"FullName\":123}", "{\"FullName\":123}", null)]
[InlineData("[1,2]", "[1,2]", null)]
[InlineData("not json {", "not json {", null)]
[InlineData(" ", " ", null)]
public void FullName_and_ExplicitFullName(string cfg, string expectedFullName, string? expectedExplicit)
{
var intent = TagConfigIntent.Parse(cfg);
intent.FullName.ShouldBe(expectedFullName);
intent.ExplicitFullName.ShouldBe(expectedExplicit);
}
// ---- Parse(null)/blank defaults (identity no longer carried) ----
[Fact]
public void Parse_null_yields_empty_FullName_and_null_explicit()
public void Parse_null_yields_all_defaults()
{
var intent = TagConfigIntent.Parse(null);
intent.FullName.ShouldBe(string.Empty);
intent.ExplicitFullName.ShouldBeNull();
intent.Alarm.ShouldBeNull();
intent.IsHistorized.ShouldBeFalse();
intent.HistorianTagname.ShouldBeNull();
intent.IsArray.ShouldBeFalse();
intent.ArrayLength.ShouldBeNull();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json {")] // malformed
[InlineData("[1,2]")] // non-object root
[InlineData("42")] // non-object root
public void Parse_non_object_yields_all_defaults(string cfg)
{
var intent = TagConfigIntent.Parse(cfg);
intent.Alarm.ShouldBeNull();
intent.IsHistorized.ShouldBeFalse();
intent.HistorianTagname.ShouldBeNull();
intent.IsArray.ShouldBeFalse();
intent.ArrayLength.ShouldBeNull();
}
// ---- Alarm (ported from ExtractTagAlarmTests) ----
[Theory]
[InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
[InlineData("{\"nodeId\":\"X.Y\"}", false, null, 0)]
[InlineData("{\"nodeId\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)]
[InlineData("{\"nodeId\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
[InlineData("not json", false, null, 0)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)]
[InlineData("{\"nodeId\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)]
[InlineData("{\"alarm\":{\"severity\":\"high\"}}", true, "AlarmCondition", 500)] // non-number severity → 500
[InlineData("{\"alarm\":{\"alarmType\":123}}", true, "AlarmCondition", 500)] // non-string type → default
public void Alarm_parses_or_null(string cfg, bool present, string? type, int sev)
@@ -73,12 +72,12 @@ public sealed class TagConfigIntentTests
// ---- Historize ----
[Theory]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true}", true, null)]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", true, "P.L.X")]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", false, null)]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)] // whitespace → null
[InlineData("{\"FullName\":\"A\",\"isHistorized\":\"yes\"}", false, null)] // non-bool → false
[InlineData("{\"FullName\":\"A\"}", false, null)]
[InlineData("{\"nodeId\":\"A\",\"isHistorized\":true}", true, null)]
[InlineData("{\"nodeId\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", true, "P.L.X")]
[InlineData("{\"nodeId\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", false, null)]
[InlineData("{\"nodeId\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)] // whitespace → null
[InlineData("{\"nodeId\":\"A\",\"isHistorized\":\"yes\"}", false, null)] // non-bool → false
[InlineData("{\"nodeId\":\"A\"}", false, null)]
public void Historize(string cfg, bool expectedHistorized, string? expectedTagname)
{
var intent = TagConfigIntent.Parse(cfg);
@@ -89,11 +88,11 @@ public sealed class TagConfigIntentTests
// ---- Array (ported from ExtractTagArrayTests) ----
[Theory]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)]
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)]
[InlineData("{\"nodeId\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)]
[InlineData("{\"nodeId\":\"T.A\",\"isArray\":true}", true, null)]
[InlineData("{\"nodeId\":\"T.A\"}", false, null)]
[InlineData("{\"nodeId\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)]
[InlineData("{\"nodeId\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)]
[InlineData(null, false, null)]
[InlineData("", false, null)]
[InlineData("not json {", false, null)]
@@ -1,60 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Characterization net (R2-11) pinning the DraftValidator Galaxy rule's <em>explicit</em>-FullName
/// semantics across the golden TagConfig corpus BEFORE the private FullName reader is delegated to
/// <c>TagConfigIntent.Parse(...).ExplicitFullName</c>. Distinct from the walker/composer raw-blob
/// fallback: <c>GalaxyTagMissingReference</c> fires whenever the TagConfig has no usable
/// <em>explicit</em> string <c>FullName</c> — i.e. absent / non-string / whitespace / non-object /
/// malformed — and does NOT fire only when a non-blank string FullName is present. Must be green on
/// the unmodified tree and stay green through the swap.
/// </summary>
public sealed class DraftValidatorGalaxyFullNameCorpusTests
{
[Theory]
// Present, non-blank string FullName → rule does NOT fire
[InlineData("{\"FullName\":\"X.Y\"}", false)]
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", false)]
// Absent / non-string / whitespace / non-object / malformed → rule FIRES
[InlineData("{}", true)]
[InlineData("{\"region\":\"HoldingRegisters\"}", true)]
[InlineData("{\"FullName\":123}", true)]
[InlineData("{\"FullName\":\" \"}", true)]
[InlineData("[1,2]", true)]
[InlineData("not json {", true)]
public void Galaxy_missing_reference_fires_iff_no_explicit_string_FullName(string tagConfig, bool shouldFire)
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances =
[
new DriverInstance
{
DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1",
Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
},
],
Tags =
[
new Tag
{
TagId = "tag-galaxytag", DriverInstanceId = "d-galaxy", EquipmentId = "eq-1",
Name = "galaxytag", FolderPath = null, DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
},
],
};
var fired = DraftValidator.Validate(draft)
.Any(e => e.Code == "GalaxyTagMissingReference" && e.Context == "tag-galaxytag");
fired.ShouldBe(shouldFire);
}
}
@@ -1,36 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
/// <summary>
/// Characterization net (R2-11) pinning <see cref="EquipmentNodeWalker.ExtractFullName"/>'s
/// raw-blob-fallback semantics across the golden TagConfig corpus BEFORE the parser is delegated to
/// <c>TagConfigIntent.Parse</c>. The walker's FullName copy falls back to the raw blob whenever the
/// input is not a JSON object carrying a string <c>FullName</c> (blank / non-object / malformed /
/// absent / non-string). Whitespace + non-string-property values are returned verbatim (not trimmed).
/// Must be green on the unmodified tree and stay green through the swap.
/// </summary>
public sealed class EquipmentNodeWalkerFullNameCorpusTests
{
[Theory]
// object with a string FullName → the FullName string (verbatim, incl. whitespace)
[InlineData("{\"FullName\":\"X.Y\"}", "X.Y")]
[InlineData("{\"FullName\":\" \"}", " ")]
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5}", "A.B")]
// blank → raw blob (IsNullOrWhiteSpace short-circuit)
[InlineData(" ", " ")]
// non-object root → raw blob
[InlineData("[1,2]", "[1,2]")]
// malformed JSON → raw blob
[InlineData("not json {", "not json {")]
// object, FullName absent → raw blob
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":5}", "{\"region\":\"HoldingRegisters\",\"address\":5}")]
// object, FullName present but non-string → raw blob
[InlineData("{\"FullName\":123}", "{\"FullName\":123}")]
public void ExtractFullName_falls_back_to_raw_blob_except_for_string_FullName(string tagConfig, string expected)
{
EquipmentNodeWalker.ExtractFullName(tagConfig).ShouldBe(expected);
}
}