diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor index b433f723..0f11fb91 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor @@ -170,6 +170,37 @@ } + @* Driver-agnostic array-shape intent. Merges the root `isArray` / `arrayLength` + keys onto the canonical TagConfig via the pure TagArrayConfig seam so it composes + with the typed editor's driver-specific fields (both preserve unknown keys). When + checked, the server materialises a 1-D array node (ValueRank=1). Shown for EVERY + driver once one is picked — same place/pattern as the historize control above. *@ + @if (!string.IsNullOrEmpty(_form.DriverInstanceId)) + { +
+ +
+ + +
+
+ When checked, the server materialises this tag as a fixed-length 1-D array node. +
+ @if (_arrayState.IsArray) + { +
+ + +
Number of elements (must be a positive whole number).
+
+ } +
+ } + @* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate; @@ -255,6 +286,11 @@ // the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig. private TagHistorizeConfig.HistorizeState _historizeState; + // Driver-agnostic array-shape intent (root `isArray` / `arrayLength`), reflected for the array controls. + // Re-read from _form.TagConfig whenever the modal (re)opens or the driver changes; the change handlers + // merge it back onto _form.TagConfig via TagArrayConfig (same pattern as _historizeState above). + private TagArrayConfig.ArrayState _arrayState; + // The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen. private string? SelectedDriverType => Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType; @@ -285,6 +321,8 @@ _galaxyPickedIsAlarm = false; // The reset TagConfig carries no history intent — reflect that in the historize controls. _historizeState = TagHistorizeConfig.Read(_form.TagConfig); + // Likewise the reset TagConfig carries no array intent. + _arrayState = TagArrayConfig.Read(_form.TagConfig); } // The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical @@ -304,10 +342,13 @@ JsonSerializer.Serialize(new { FullName = address }), _historizeState.IsHistorized, _historizeState.HistorianTagname); + // Re-merge any array intent for the same reason — a fresh {FullName} blob would otherwise drop it. + config = TagArrayConfig.Set(config, _arrayState.IsArray, _arrayState.ArrayLength); _form.TagConfig = _galaxyPickedIsAlarm ? NativeAlarmModel.SeedDefaultAlarm(config) : config; _historizeState = TagHistorizeConfig.Read(_form.TagConfig); + _arrayState = TagArrayConfig.Read(_form.TagConfig); } private IDictionary BuildEditorParameters() => new Dictionary @@ -372,6 +413,30 @@ _historizeState = TagHistorizeConfig.Read(_form.TagConfig); } + // Toggle the root `isArray` array-shape intent in the raw TagConfig, merged via the pure TagArrayConfig + // seam so the typed editor's driver-specific keys are preserved. Clearing it drops `arrayLength` too + // (no orphan length), so the carried _arrayState.ArrayLength is irrelevant when unchecking. + private void OnIsArrayChanged(ChangeEventArgs e) + { + var isArray = e.Value is true; + _form.TagConfig = TagArrayConfig.Set(_form.TagConfig, isArray, _arrayState.ArrayLength); + _arrayState = TagArrayConfig.Read(_form.TagConfig); + } + + // Merge the array length (root `arrayLength`) into the raw TagConfig. A blank/zero/negative/non-numeric + // entry parses to null, so the key is dropped until a positive length is typed (and SaveAsync rejects + // an array with no positive length). + private void OnArrayLengthChanged(ChangeEventArgs e) + { + var length = ParsePositiveLength(e.Value?.ToString()); + _form.TagConfig = TagArrayConfig.Set(_form.TagConfig, _arrayState.IsArray, length); + _arrayState = TagArrayConfig.Read(_form.TagConfig); + } + + // Parse the numeric-input string to a positive uint, or null for blank/zero/negative/overflow/non-numeric. + private static uint? ParsePositiveLength(string? raw) + => uint.TryParse(raw, out var u) && u > 0 ? u : null; + protected override void OnParametersSet() { // Rebuild the working form whenever the host (re)opens the modal for a fresh target. @@ -400,6 +465,8 @@ _galaxyAddress = ReadFullName(_form.TagConfig); // Seed the historize controls from any existing root isHistorized/historianTagname keys. _historizeState = TagHistorizeConfig.Read(_form.TagConfig); + // Seed the array controls from any existing root isArray/arrayLength keys. + _arrayState = TagArrayConfig.Read(_form.TagConfig); } // Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable. @@ -436,6 +503,15 @@ return; } + // Driver-agnostic array-shape validation: an array tag needs a positive length. Mirrors the + // per-driver config validation above so a missing length is caught here rather than at deploy. + var arrayError = TagArrayConfig.Validate(_arrayState.IsArray, _arrayState.ArrayLength); + if (arrayError is not null) + { + _error = arrayError; + return; + } + var input = new TagInput( _form.TagId, _form.Name, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagArrayConfig.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagArrayConfig.cs new file mode 100644 index 00000000..9609a187 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagArrayConfig.cs @@ -0,0 +1,68 @@ +using System.Text.Json.Nodes; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Pure, driver-agnostic merge helper for the two server-side array-shape intent keys at the ROOT of a +/// tag's TagConfig JSON: isArray (camelCase bool — omit/false default) and arrayLength +/// (camelCase JSON number — the 1-D array length; omit when not an array). These map to what the +/// EquipmentTagPlan reads to materialise a 1-D array OPC UA node (ValueRank=1, ArrayDimensions=[length]). +/// +/// +/// This is a TagModal-merge seam mirroring : the TagModal owns the +/// canonical TagConfig JSON; the driver's typed editor reads/writes its driver-specific fields on that same +/// JSON, and the modal's "This tag is an array" checkbox + "Array length" numeric input read/write these two +/// keys through this helper. They COMPOSE because both sides preserve every unrecognised key (delegated to +/// / ), so neither clobbers the +/// other's fields. These keys are intentionally NOT carried inside any per-driver typed model. +/// +/// +public static class TagArrayConfig +{ + /// The two array-shape values parsed from a TagConfig JSON's root. + /// Whether the server materialises this tag as a 1-D array node. + /// The 1-D array length; null when absent (incl. when not an array). + public readonly record struct ArrayState(bool IsArray, uint? ArrayLength); + + /// Reads the array-shape keys from a TagConfig JSON, defaulting any absent key + /// (null/blank/malformed input ⇒ (false, null)). A negative or non-numeric arrayLength + /// reads as null. + public static ArrayState Read(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new ArrayState( + TagConfigJson.GetBool(o, "isArray"), + ReadLength(o)); + } + + /// Merges the two array-shape keys into , preserving every other + /// (driver-specific or unknown) key. isArray is dropped when false (absent ⇒ false at the + /// materialiser); arrayLength is dropped when is false or + /// is null (never leaves an orphan length behind a cleared isArray). + public static string Set(string? json, bool isArray, uint? arrayLength) + { + var o = TagConfigJson.ParseOrNew(json); + TagConfigJson.Set(o, "isArray", isArray ? true : null); + // Drop arrayLength whenever isArray is off OR no length is set, so a cleared array never + // leaves an orphan length key behind. + TagConfigJson.Set(o, "arrayLength", isArray && arrayLength is { } len ? len : null); + return TagConfigJson.Serialize(o); + } + + /// Validates the array-shape intent: when is set, a positive + /// is required. Returns an error string when invalid, or null + /// when valid (a non-array is always valid regardless of length). + public static string? Validate(bool isArray, uint? arrayLength) + => isArray && (arrayLength is null or 0) + ? "Array length must be a positive number when 'This tag is an array' is checked." + : null; + + // Reads arrayLength as a non-negative uint, or null when absent/null/non-numeric/negative. + private static uint? ReadLength(JsonObject o) + => o.TryGetPropertyValue("arrayLength", out var n) + && n is JsonValue v + && v.TryGetValue(out var l) + && l is >= 0 and <= uint.MaxValue + ? (uint)l + : null; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagArrayConfigTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagArrayConfigTests.cs new file mode 100644 index 00000000..a10ef54c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagArrayConfigTests.cs @@ -0,0 +1,177 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Unit tests for the driver-agnostic merge helper — the pure seam the +/// TagModal uses to read/write the root isArray / arrayLength array keys on the canonical +/// TagConfig JSON WITHOUT disturbing the driver's own (typed-editor) fields. Both this helper and every +/// typed editor preserve unknown keys, so they compose over the same JSON blob. Mirrors +/// . +/// +public sealed class TagArrayConfigTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + [InlineData("not json")] + public void Read_returns_defaults_for_empty_or_malformed(string? json) + { + var a = TagArrayConfig.Read(json); + + a.IsArray.ShouldBeFalse(); + a.ArrayLength.ShouldBeNull(); + } + + [Fact] + public void Read_reads_both_keys() + { + var a = TagArrayConfig.Read("""{"FullName":"ns=2;s=X","isArray":true,"arrayLength":8}"""); + + a.IsArray.ShouldBeTrue(); + a.ArrayLength.ShouldBe(8u); + } + + [Fact] + public void Set_true_with_length_writes_both_camelCase_keys() + { + var json = TagArrayConfig.Set("""{"FullName":"ns=2;s=X"}""", isArray: true, arrayLength: 8); + + json.ShouldContain("\"isArray\":true"); + json.ShouldContain("\"arrayLength\":8"); + // The driver field is untouched. + json.ShouldContain("\"FullName\":\"ns=2;s=X\""); + } + + [Fact] + public void Set_true_round_trips_through_Read() + { + var json = TagArrayConfig.Set("{}", isArray: true, arrayLength: 16); + + var a = TagArrayConfig.Read(json); + a.IsArray.ShouldBeTrue(); + a.ArrayLength.ShouldBe(16u); + } + + [Fact] + public void Set_false_removes_both_keys() + { + var json = TagArrayConfig.Set( + """{"FullName":"ns=2;s=X","isArray":true,"arrayLength":8}""", + isArray: false, arrayLength: 8); + + json.ShouldNotContain("isArray"); + json.ShouldNotContain("arrayLength"); + json.ShouldContain("\"FullName\":\"ns=2;s=X\""); + } + + [Fact] + public void Set_false_removes_orphan_arrayLength() + { + // Clearing isArray must not leave an orphan arrayLength behind. + var json = TagArrayConfig.Set( + """{"isArray":true,"arrayLength":4}""", + isArray: false, arrayLength: 4); + + json.ShouldNotContain("arrayLength"); + json.ShouldNotContain("isArray"); + } + + [Fact] + public void Set_true_null_length_drops_arrayLength_key() + { + // isArray on but no length yet (pre-validation authoring state): emit isArray, no arrayLength. + var json = TagArrayConfig.Set("{}", isArray: true, arrayLength: null); + + json.ShouldContain("\"isArray\":true"); + json.ShouldNotContain("arrayLength"); + } + + [Fact] + public void Set_preserves_driver_specific_unknown_keys() + { + var json = TagArrayConfig.Set( + """{"register":40001,"scale":0.1,"nested":{"a":1}}""", + isArray: true, arrayLength: 32); + + json.ShouldContain("register"); + json.ShouldContain("40001"); + json.ShouldContain("scale"); + json.ShouldContain("0.1"); + json.ShouldContain("nested"); + json.ShouldContain("\"isArray\":true"); + json.ShouldContain("\"arrayLength\":32"); + } + + [Fact] + public void Set_on_malformed_input_starts_from_empty_object() + { + var json = TagArrayConfig.Set("not json", isArray: true, arrayLength: 2); + + json.ShouldContain("\"isArray\":true"); + json.ShouldContain("\"arrayLength\":2"); + json.ShouldStartWith("{"); + json.ShouldEndWith("}"); + } + + [Fact] + public void Validate_rejects_array_with_null_length() + { + TagArrayConfig.Validate(isArray: true, arrayLength: null).ShouldNotBeNull(); + } + + [Fact] + public void Validate_rejects_array_with_zero_length() + { + TagArrayConfig.Validate(isArray: true, arrayLength: 0).ShouldNotBeNull(); + } + + [Fact] + public void Validate_accepts_array_with_positive_length() + { + TagArrayConfig.Validate(isArray: true, arrayLength: 8).ShouldBeNull(); + } + + [Fact] + public void Validate_accepts_non_array_regardless_of_length() + { + TagArrayConfig.Validate(isArray: false, arrayLength: null).ShouldBeNull(); + TagArrayConfig.Validate(isArray: false, arrayLength: 0).ShouldBeNull(); + } + + /// + /// Full seam: the array helper and a driver-typed editor compose over the same canonical TagConfig + /// blob without clobbering each other. Setting the array keys, round-tripping through the OpcUaClient + /// typed editor (FromJson → ToJson), then reading the array keys back recovers them intact — and the + /// editor's own FullName field survives the array merge. + /// + [Fact] + public void Array_keys_survive_a_typed_editor_round_trip() + { + var withArray = TagArrayConfig.Set("""{"FullName":"ns=2;s=X"}""", isArray: true, arrayLength: 12); + + var afterEditor = OpcUaClientTagConfigModel.FromJson(withArray).ToJson(); + + var a = TagArrayConfig.Read(afterEditor); + a.IsArray.ShouldBeTrue(); + a.ArrayLength.ShouldBe(12u); + OpcUaClientTagConfigModel.FromJson(afterEditor).FullName.ShouldBe("ns=2;s=X"); + } + + /// The array helper and the historize helper compose over the same blob (both preserve + /// unknown keys), so a tag can carry both array and history intent simultaneously. + [Fact] + public void Array_and_historize_keys_compose() + { + var withHistory = TagHistorizeConfig.Set("""{"FullName":"ns=2;s=X"}""", isHistorized: true, historianTagname: "TN"); + var withBoth = TagArrayConfig.Set(withHistory, isArray: true, arrayLength: 4); + + TagArrayConfig.Read(withBoth).ArrayLength.ShouldBe(4u); + TagHistorizeConfig.Read(withBoth).IsHistorized.ShouldBeTrue(); + TagHistorizeConfig.Read(withBoth).HistorianTagname.ShouldBe("TN"); + } +}