diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
index ba81fcf2..97c5b5dc 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
@@ -8,28 +8,17 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// (AddressSpaceComposer), the artifact-decode seam (DeploymentArtifact), the draft
/// gate (DraftValidator), and the walker (EquipmentNodeWalker). One
/// per blob. Never throws.
-/// Two deliberate variations are carried on the record so every seam can be served from one
-/// parse:
-///
-/// - is the driver-side wire reference: the top-level
-/// FullName string when present, else the RAW blob (an equipment tag's TagConfig JSON
-/// is its wire reference for the six protocol drivers). Parse(null) ⇒
-/// FullName == "".
-/// - is the FullName property ONLY when
-/// present-and-string, else — the DraftValidator Galaxy-rule semantics
-/// (it wants the explicit reference, not the raw-blob fallback).
-///
+/// Under the v3 identity contract, TagConfig no longer carries identity — the
+/// RawPath (computed elsewhere) is the single identity string at every seam. This type parses
+/// only the cross-driver PLATFORM intents (alarm / historize / array); the driver-specific
+/// address fields stay opaque inside the blob and are read by the per-driver resolvers.
///
-/// Top-level FullName string, else the raw blob (wire-reference fallback).
-/// The FullName property when present-and-string, else .
/// The optional native-alarm intent parsed from the alarm object; ⇒ plain value variable.
/// isHistorized — true only when the value is a bool true.
/// Non-whitespace historianTagname string override, else (not trimmed).
/// isArray bool.
/// arrayLength honoured ONLY when and a JSON number fitting ; else .
public sealed record TagConfigIntent(
- string FullName,
- string? ExplicitFullName,
TagAlarmIntent? Alarm,
bool IsHistorized,
string? HistorianTagname,
@@ -38,40 +27,33 @@ public sealed record TagConfigIntent(
{
///
/// Parse a Tag's schemaless TagConfig JSON into the cross-driver platform intents.
- /// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default with
- /// = the raw blob (or "" for null). Semantics are ported verbatim from the
- /// historical AddressSpaceComposer.Extract* statics (the canonical copy).
+ /// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default.
+ /// Semantics are ported verbatim from the historical AddressSpaceComposer.Extract*
+ /// statics (the canonical copy).
///
/// The tag's raw TagConfig JSON (nullable/blank tolerated).
/// The parsed intents; never .
public static TagConfigIntent Parse(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig))
- return new TagConfigIntent(tagConfig ?? string.Empty, null, null, false, null, false, null);
+ return new TagConfigIntent(null, false, null, false, null);
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
- return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
-
- var explicitFullName =
- root.TryGetProperty("FullName", out var fn) && fn.ValueKind == JsonValueKind.String
- ? fn.GetString()
- : null;
- var fullName = explicitFullName ?? tagConfig;
+ return new TagConfigIntent(null, false, null, false, null);
var (isHistorized, historianTagname) = ParseHistorize(root);
var (isArray, arrayLength) = ParseArray(root);
return new TagConfigIntent(
- fullName, explicitFullName, ParseAlarm(root),
- isHistorized, historianTagname, isArray, arrayLength);
+ ParseAlarm(root), isHistorized, historianTagname, isArray, arrayLength);
}
catch (JsonException)
{
- return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
+ return new TagConfigIntent(null, false, null, false, null);
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs
index 86deb8ee..f3925231 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs
@@ -3,21 +3,24 @@ using System.Text.Json.Nodes;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// Typed working model for an OPC UA Client (gateway) equipment tag's TagConfig JSON. The tag
-/// is bound to the upstream OPC UA server node by its full reference (FullName — the persisted
+/// is bound to the upstream OPC UA server node by its full reference (nodeId — the persisted
/// stable nsu=…;s=… or plain ns=2;s=… NodeId the driver reads/writes/subscribes against).
/// Preserves unrecognised JSON keys across a load→save.
///
-/// The FullName key is intentionally PascalCase: the shared deploy-time parser
-/// (Commons.Types.TagConfigIntent.Parse, consumed by the composer, artifact decoder, draft
-/// gate, and node walker) reads it via a case-sensitive TryGetProperty("FullName", …), so the editor MUST persist that exact
-/// casing. The driver-agnostic server-side HistoryRead intent keys (isHistorized /
+/// Under the v3 identity contract, TagConfig no longer carries identity — the upstream
+/// node reference is an ordinary driver-specific address field. The key is the camelCase
+/// nodeId (replacing the legacy PascalCase FullName identity convention). The
+/// driver-agnostic server-side HistoryRead intent keys (isHistorized /
/// historianTagname) are NOT modelled here — they are owned by the TagModal-merge seam
/// () and survive a load→save of this model as preserved unknown keys.
///
public sealed class OpcUaClientTagConfigModel
{
+ /// The camelCase TagConfig JSON key holding the upstream node reference.
+ internal const string NodeIdKey = "nodeId";
+
/// Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.
- public string FullName { get; set; } = "";
+ public string NodeId { get; set; } = "";
private JsonObject _bag = new();
@@ -30,24 +33,24 @@ public sealed class OpcUaClientTagConfigModel
var o = TagConfigJson.ParseOrNew(json);
return new OpcUaClientTagConfigModel
{
- FullName = TagConfigJson.GetString(o, "FullName") ?? "",
+ NodeId = TagConfigJson.GetString(o, NodeIdKey) ?? "",
_bag = o,
};
}
/// Serialises this model back to a TagConfig JSON string over the preserved key bag.
- /// FullName is written PascalCase (the composer/walker contract key); any history keys merged
- /// by the TagModal (isHistorized / historianTagname) are carried through untouched as
- /// preserved unknown keys.
+ /// nodeId is written camelCase (the v3 address key); any history keys merged by the TagModal
+ /// (isHistorized / historianTagname) are carried through untouched as preserved unknown
+ /// keys.
/// The serialised TagConfig JSON string.
public string ToJson()
{
- TagConfigJson.Set(_bag, "FullName", FullName.Trim());
+ TagConfigJson.Set(_bag, NodeIdKey, NodeId.Trim());
return TagConfigJson.Serialize(_bag);
}
/// Validation hook; returns an error message or null when the model is valid.
/// An error message describing the validation failure, or null when valid.
public string? Validate()
- => string.IsNullOrWhiteSpace(FullName) ? "An upstream node reference (FullName) is required." : null;
+ => string.IsNullOrWhiteSpace(NodeId) ? "An upstream node reference (nodeId) is required." : null;
}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/RawPathsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/RawPathsTests.cs
new file mode 100644
index 00000000..31ea28c8
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/RawPathsTests.cs
@@ -0,0 +1,207 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
+
+///
+/// The v3 identity authority (): 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.
+///
+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 { "A", "B" }).ShouldBe("A/B");
+ }
+
+ [Fact]
+ public void Build_empty_list_throws()
+ {
+ Should.Throw(() => RawPaths.Build(Array.Empty()));
+ }
+
+ [Fact]
+ public void Build_null_throws()
+ {
+ Should.Throw(() => RawPaths.Build((IEnumerable)null!));
+ }
+
+ [Fact]
+ public void Build_invalid_segment_throws_naming_the_index()
+ {
+ var ex = Should.Throw(() => 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(() => RawPaths.Combine("A/B", "c/d"));
+ Should.Throw(() => 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");
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs
index b19f46fb..39d1fd18 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs
@@ -6,50 +6,49 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
///
/// The single byte-parity authority for the cross-driver TagConfig platform intents (R2-11, 01/C-1).
-/// Ports the three retired OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests tables and
-/// adds the /
-/// distinction plus the never-throws property.
+/// Ports the three retired OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests tables plus
+/// the never-throws property. Under the v3 identity contract TagConfigIntent no longer derives
+/// any FullName/identity — only the platform intents (alarm / historize / array) are parsed here.
///
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)]
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs
deleted file mode 100644
index ae74919d..00000000
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
-
-namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
-
-///
-/// Characterization net (R2-11) pinning 's
-/// raw-blob-fallback semantics across the golden TagConfig corpus BEFORE the parser is delegated to
-/// TagConfigIntent.Parse. The walker's FullName copy falls back to the raw blob whenever the
-/// input is not a JSON object carrying a string FullName (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.
-///
-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);
- }
-}