diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index 19647b38..6bbe0462 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -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"] }, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs new file mode 100644 index 00000000..ba81fcf2 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs @@ -0,0 +1,129 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// The cross-driver platform intents parsed from a Tag's schemaless TagConfig JSON. +/// SINGLE SOURCE OF TRUTH for the byte-parity contract shared by the live-compose seam +/// (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). +/// +/// +/// 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, + bool IsArray, + uint? ArrayLength) +{ + /// + /// 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). + /// + /// 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); + + 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); + } +} + +/// The optional native-alarm intent parsed from a tag's TagConfig.alarm object. +/// OPC UA Part 9 subtype string; default "AlarmCondition". +/// 1..1000 severity; default 500. +/// Per-condition durable-write opt-out (bool?; absent ⇒ ⇒ historize). +public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs new file mode 100644 index 00000000..b19f46fb --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs @@ -0,0 +1,128 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +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. +/// +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)); + } +}