feat(commons): TagConfigIntent.Parse — single home for the byte-parity TagConfig intents (R2-11, 01/C-1)
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"tasks": [
|
||||
{ "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "pending", "blockedBy": [] },
|
||||
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "pending", "blockedBy": [] },
|
||||
{ "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "pending", "blockedBy": ["T1", "T3"] },
|
||||
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] },
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// The cross-driver platform intents parsed from a Tag's schemaless <c>TagConfig</c> JSON.
|
||||
/// SINGLE SOURCE OF TRUTH for the byte-parity contract shared by the live-compose seam
|
||||
/// (<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>
|
||||
/// </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,
|
||||
bool IsArray,
|
||||
uint? ArrayLength)
|
||||
{
|
||||
/// <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).
|
||||
/// </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);
|
||||
|
||||
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;
|
||||
|
||||
var (isHistorized, historianTagname) = ParseHistorize(root);
|
||||
var (isArray, arrayLength) = ParseArray(root);
|
||||
|
||||
return new TagConfigIntent(
|
||||
fullName, explicitFullName, ParseAlarm(root),
|
||||
isHistorized, historianTagname, isArray, arrayLength);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static TagAlarmIntent? ParseAlarm(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
|
||||
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
||||
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
||||
&& sEl.TryGetInt32(out var sv) ? sv : 500;
|
||||
// historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the
|
||||
// durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on).
|
||||
bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
|
||||
&& hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? hEl.GetBoolean()
|
||||
: null;
|
||||
return new TagAlarmIntent(type, sev, historize);
|
||||
}
|
||||
|
||||
private static (bool IsHistorized, string? HistorianTagname) ParseHistorize(JsonElement root)
|
||||
{
|
||||
var isHistorized = root.TryGetProperty("isHistorized", out var hEl)
|
||||
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
||||
&& hEl.GetBoolean();
|
||||
string? tagname = null;
|
||||
if (root.TryGetProperty("historianTagname", out var nEl) && nEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = nEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(raw)) tagname = raw; // not trimmed
|
||||
}
|
||||
return (isHistorized, tagname);
|
||||
}
|
||||
|
||||
private static (bool IsArray, uint? ArrayLength) ParseArray(JsonElement root)
|
||||
{
|
||||
var isArray = root.TryGetProperty("isArray", out var aEl)
|
||||
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
|
||||
&& aEl.GetBoolean();
|
||||
uint? arrayLength = null;
|
||||
if (isArray
|
||||
&& root.TryGetProperty("arrayLength", out var lEl)
|
||||
&& lEl.ValueKind == JsonValueKind.Number
|
||||
&& lEl.TryGetUInt32(out var len))
|
||||
{
|
||||
arrayLength = len;
|
||||
}
|
||||
return (isArray, arrayLength);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The optional native-alarm intent parsed from a tag's <c>TagConfig.alarm</c> object.</summary>
|
||||
/// <param name="AlarmType">OPC UA Part 9 subtype string; default <c>"AlarmCondition"</c>.</param>
|
||||
/// <param name="Severity">1..1000 severity; default <c>500</c>.</param>
|
||||
/// <param name="HistorizeToAveva">Per-condition durable-write opt-out (<c>bool?</c>; absent ⇒ <see langword="null"/> ⇒ historize).</param>
|
||||
public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva);
|
||||
@@ -0,0 +1,128 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
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.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_null_yields_empty_FullName_and_null_explicit()
|
||||
{
|
||||
var intent = TagConfigIntent.Parse(null);
|
||||
intent.FullName.ShouldBe(string.Empty);
|
||||
intent.ExplicitFullName.ShouldBeNull();
|
||||
intent.Alarm.ShouldBeNull();
|
||||
intent.IsHistorized.ShouldBeFalse();
|
||||
intent.IsArray.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- 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("not json", false, null, 0)]
|
||||
[InlineData("{\"FullName\":\"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)
|
||||
{
|
||||
var alarm = TagConfigIntent.Parse(cfg).Alarm;
|
||||
if (!present) { alarm.ShouldBeNull(); return; }
|
||||
alarm!.AlarmType.ShouldBe(type);
|
||||
alarm.Severity.ShouldBe(sev);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)]
|
||||
public void Alarm_historizeToAveva(string cfg, bool? expected)
|
||||
{
|
||||
TagConfigIntent.Parse(cfg).Alarm!.HistorizeToAveva.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- 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)]
|
||||
public void Historize(string cfg, bool expectedHistorized, string? expectedTagname)
|
||||
{
|
||||
var intent = TagConfigIntent.Parse(cfg);
|
||||
intent.IsHistorized.ShouldBe(expectedHistorized);
|
||||
intent.HistorianTagname.ShouldBe(expectedTagname);
|
||||
}
|
||||
|
||||
// ---- 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(null, false, null)]
|
||||
[InlineData("", false, null)]
|
||||
[InlineData("not json {", false, null)]
|
||||
[InlineData("[1,2]", false, null)]
|
||||
[InlineData("{\"isArray\":\"yes\"}", false, null)]
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)]
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)]
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)]
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)]
|
||||
public void Array_parses_or_defaults(string? cfg, bool expectedIsArray, uint? expectedLength)
|
||||
{
|
||||
var intent = TagConfigIntent.Parse(cfg);
|
||||
intent.IsArray.ShouldBe(expectedIsArray);
|
||||
intent.ArrayLength.ShouldBe(expectedLength);
|
||||
}
|
||||
|
||||
// ---- never-throws property over an adversarial set ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not json {")]
|
||||
[InlineData("[1,2,3]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("\"just a string\"")]
|
||||
[InlineData("{\"alarm\":42,\"isArray\":\"x\",\"arrayLength\":true,\"isHistorized\":123}")]
|
||||
public void Parse_never_throws(string? cfg)
|
||||
{
|
||||
Should.NotThrow(() => TagConfigIntent.Parse(cfg));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user