From d4dab753235512f047efb7662df6f9c7e7b28212 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 18:58:57 -0400 Subject: [PATCH] v3 B1-WP2: TagConfigIntent sheds FullName identity; RawPaths tests; nodeId key normalization --- .../Types/TagConfigIntent.cs | 40 +--- .../TagEditors/OpcUaClientTagConfigModel.cs | 27 ++- .../RawPathsTests.cs | 207 ++++++++++++++++++ .../TagConfigIntentTests.cs | 77 ++++--- ...DraftValidatorGalaxyFullNameCorpusTests.cs | 60 ----- .../EquipmentNodeWalkerFullNameCorpusTests.cs | 36 --- 6 files changed, 271 insertions(+), 176 deletions(-) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/RawPathsTests.cs delete mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs delete mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs 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.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs deleted file mode 100644 index e4af659e..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs +++ /dev/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; - -/// -/// Characterization net (R2-11) pinning the DraftValidator Galaxy rule's explicit-FullName -/// semantics across the golden TagConfig corpus BEFORE the private FullName reader is delegated to -/// TagConfigIntent.Parse(...).ExplicitFullName. Distinct from the walker/composer raw-blob -/// fallback: GalaxyTagMissingReference fires whenever the TagConfig has no usable -/// explicit string FullName — 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. -/// -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); - } -} 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); - } -}