v3 B1-WP2: TagConfigIntent sheds FullName identity; RawPaths tests; nodeId key normalization
This commit is contained in:
@@ -8,28 +8,17 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
/// (<c>AddressSpaceComposer</c>), the artifact-decode seam (<c>DeploymentArtifact</c>), the draft
|
||||
/// gate (<c>DraftValidator</c>), and the walker (<c>EquipmentNodeWalker</c>). One
|
||||
/// <see cref="JsonDocument.Parse(string)"/> per blob. Never throws.
|
||||
/// <para>Two deliberate variations are carried on the record so every seam can be served from one
|
||||
/// parse:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="FullName"/> is the driver-side wire reference: the top-level
|
||||
/// <c>FullName</c> string when present, else the RAW blob (an equipment tag's TagConfig JSON
|
||||
/// <em>is</em> its wire reference for the six protocol drivers). <c>Parse(null)</c> ⇒
|
||||
/// <c>FullName == ""</c>.</description></item>
|
||||
/// <item><description><see cref="ExplicitFullName"/> is the <c>FullName</c> property ONLY when
|
||||
/// present-and-string, else <see langword="null"/> — the DraftValidator Galaxy-rule semantics
|
||||
/// (it wants the explicit reference, not the raw-blob fallback).</description></item>
|
||||
/// </list>
|
||||
/// <para>Under the v3 identity contract, <c>TagConfig</c> 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.</para>
|
||||
/// </summary>
|
||||
/// <param name="FullName">Top-level <c>FullName</c> string, else the raw blob (wire-reference fallback).</param>
|
||||
/// <param name="ExplicitFullName">The <c>FullName</c> property when present-and-string, else <see langword="null"/>.</param>
|
||||
/// <param name="Alarm">The optional native-alarm intent parsed from the <c>alarm</c> object; <see langword="null"/> ⇒ plain value variable.</param>
|
||||
/// <param name="IsHistorized"><c>isHistorized</c> — true only when the value is a bool <c>true</c>.</param>
|
||||
/// <param name="HistorianTagname">Non-whitespace <c>historianTagname</c> string override, else <see langword="null"/> (not trimmed).</param>
|
||||
/// <param name="IsArray"><c>isArray</c> bool.</param>
|
||||
/// <param name="ArrayLength"><c>arrayLength</c> honoured ONLY when <see cref="IsArray"/> and a JSON number fitting <see cref="uint"/>; else <see langword="null"/>.</param>
|
||||
public sealed record TagConfigIntent(
|
||||
string FullName,
|
||||
string? ExplicitFullName,
|
||||
TagAlarmIntent? Alarm,
|
||||
bool IsHistorized,
|
||||
string? HistorianTagname,
|
||||
@@ -38,40 +27,33 @@ public sealed record TagConfigIntent(
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse a Tag's schemaless <c>TagConfig</c> JSON into the cross-driver platform intents.
|
||||
/// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default with
|
||||
/// <see cref="FullName"/> = the raw blob (or "" for null). Semantics are ported verbatim from the
|
||||
/// historical <c>AddressSpaceComposer.Extract*</c> statics (the canonical copy).
|
||||
/// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default.
|
||||
/// Semantics are ported verbatim from the historical <c>AddressSpaceComposer.Extract*</c>
|
||||
/// statics (the canonical copy).
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed intents; never <see langword="null"/>.</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,24 @@ using System.Text.Json.Nodes;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>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 (<c>FullName</c> — the persisted
|
||||
/// is bound to the upstream OPC UA server node by its full reference (<c>nodeId</c> — the persisted
|
||||
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against).
|
||||
/// Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
/// <remarks>
|
||||
/// The <c>FullName</c> key is intentionally PascalCase: the shared deploy-time parser
|
||||
/// (<c>Commons.Types.TagConfigIntent.Parse</c>, consumed by the composer, artifact decoder, draft
|
||||
/// gate, and node walker) reads it via a case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
||||
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
|
||||
/// Under the v3 identity contract, <c>TagConfig</c> no longer carries identity — the upstream
|
||||
/// node reference is an ordinary driver-specific address field. The key is the camelCase
|
||||
/// <c>nodeId</c> (replacing the legacy PascalCase <c>FullName</c> identity convention). The
|
||||
/// driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
|
||||
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
|
||||
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientTagConfigModel
|
||||
{
|
||||
/// <summary>The camelCase TagConfig JSON key holding the upstream node reference.</summary>
|
||||
internal const string NodeIdKey = "nodeId";
|
||||
|
||||
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
|
||||
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
|
||||
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
|
||||
/// preserved unknown keys.</summary>
|
||||
/// <c>nodeId</c> is written camelCase (the v3 address key); any history keys merged by the TagModal
|
||||
/// (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as preserved unknown
|
||||
/// keys.</summary>
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
|
||||
TagConfigJson.Set(_bag, NodeIdKey, NodeId.Trim());
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
-60
@@ -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);
|
||||
}
|
||||
}
|
||||
-36
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user