From 2dd723e195d3c8458736769407ecc1ea2f87a93d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 19 Jun 2026 02:02:06 -0400 Subject: [PATCH] fix(adminui): preserve edited alarm fields on Galaxy address re-pick --- .../Components/Shared/Uns/TagModal.razor | 14 ++-- .../Uns/TagEditors/TagConfigJson.cs | 15 ++++ .../Uns/GalaxyAddressRepickMergeTests.cs | 84 +++++++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/GalaxyAddressRepickMergeTests.cs 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 2cd5c3b9..677656b9 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 @@ -336,14 +336,12 @@ private void OnGalaxyAddressPicked(string address) { _galaxyAddress = address; - // A fresh {FullName} blob would drop any tag-level historize the operator already set on this - // edit; re-merge it so re-picking a Galaxy address never silently clears "Historize this tag". - var config = TagHistorizeConfig.Set( - 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); + // Re-picking a Galaxy address owns ONLY the address-derived FullName key — apply it over the EXISTING + // TagConfig so every other user-edited field survives verbatim. This preserves a hand-authored `alarm` + // object (FB-4: a re-pick must never clobber edited alarm fields) as well as the root history/array + // intent and any driver/unknown keys. TagConfigJson.SetFullName uses the same preserve-unknown idiom + // as the historize/array merge seams. + var config = TagConfigJson.SetFullName(_form.TagConfig, address); _form.TagConfig = _galaxyPickedIsAlarm ? NativeAlarmModel.SeedDefaultAlarm(config) : config; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs index 8f317a4c..136fcc34 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs @@ -43,4 +43,19 @@ public static class TagConfigJson if (value is null) { o.Remove(name); return; } o[name] = JsonValue.Create(value is Enum e ? e.ToString() : value); } + + /// + /// Merges a freshly-picked Galaxy reference (tag_name.AttributeName) into + /// by setting ONLY the canonical PascalCase FullName key — every other key is preserved verbatim. + /// This is the Galaxy address re-pick seam: re-picking an address must update the bound attribute WITHOUT + /// discarding fields the operator already edited independently of the address — ESPECIALLY a hand-authored + /// alarm object (and root history/array intent, scaling, etc.). Null/blank/malformed input starts + /// from a bare {"FullName":...} object. + /// + public static string SetFullName(string? json, string fullName) + { + var o = ParseOrNew(json); + o["FullName"] = JsonValue.Create(fullName); + return Serialize(o); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/GalaxyAddressRepickMergeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/GalaxyAddressRepickMergeTests.cs new file mode 100644 index 00000000..109456f5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/GalaxyAddressRepickMergeTests.cs @@ -0,0 +1,84 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Unit tests for — the pure merge helper the TagModal's Galaxy +/// address-picked handler uses to apply a freshly-picked tag_name.AttributeName reference. Re-picking +/// a Galaxy address must update ONLY the address-derived FullName key and carry every other +/// user-edited field over verbatim — ESPECIALLY a manually-authored alarm object (FB-4 / B2). The +/// previous handler rebuilt the config from a bare {"FullName":...} blob, silently dropping the alarm. +/// +public sealed class GalaxyAddressRepickMergeTests +{ + [Fact] + public void Repick_preserves_an_already_edited_alarm_object() + { + // The operator authored an alarm, then re-picks the bound attribute to fix a typo. + const string current = + """{"FullName":"Pump_001.OldAttr","alarm":{"alarmType":"LimitAlarm","severity":250,"historizeToAveva":false}}"""; + + var merged = TagConfigJson.SetFullName(current, "Pump_001.NewAttr"); + + // The new address is applied... + var fullName = ReadFullName(merged); + fullName.ShouldBe("Pump_001.NewAttr"); + // ...and the hand-edited alarm survives the re-pick intact. + var alarm = NativeAlarmModel.FromJson(merged); + alarm.IsAlarm.ShouldBeTrue(); + alarm.AlarmType.ShouldBe("LimitAlarm"); + alarm.Severity.ShouldBe(250); + alarm.HistorizeToAveva.ShouldBe(false); + } + + [Fact] + public void Repick_with_no_alarm_just_sets_the_address() + { + var merged = TagConfigJson.SetFullName("""{"FullName":"Pump_001.OldAttr"}""", "Pump_001.NewAttr"); + + ReadFullName(merged).ShouldBe("Pump_001.NewAttr"); + NativeAlarmModel.FromJson(merged).IsAlarm.ShouldBeFalse(); + } + + [Fact] + public void Repick_preserves_other_user_edited_non_address_fields() + { + // History intent + an array shape + an arbitrary scaling key the operator set independently of the + // address — all must survive a re-pick that only owns FullName. + const string current = + """{"FullName":"Pump_001.OldAttr","isHistorized":true,"historianTagname":"Line3_Temp","isArray":true,"arrayLength":8,"scale":0.1}"""; + + var merged = TagConfigJson.SetFullName(current, "Pump_001.NewAttr"); + + ReadFullName(merged).ShouldBe("Pump_001.NewAttr"); + + var hist = TagHistorizeConfig.Read(merged); + hist.IsHistorized.ShouldBeTrue(); + hist.HistorianTagname.ShouldBe("Line3_Temp"); + + var arr = TagArrayConfig.Read(merged); + arr.IsArray.ShouldBeTrue(); + arr.ArrayLength.ShouldBe(8u); + + merged.ShouldContain("\"scale\":0.1"); + } + + [Fact] + public void Set_on_empty_or_malformed_input_starts_from_a_bare_FullName_object() + { + var merged = TagConfigJson.SetFullName("not json", "Pump_001.NewAttr"); + + ReadFullName(merged).ShouldBe("Pump_001.NewAttr"); + merged.ShouldStartWith("{"); + merged.ShouldEndWith("}"); + } + + // Mirror of TagModal.ReadFullName — extracts FullName from a Galaxy TagConfig blob. + private static string ReadFullName(string json) + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty("FullName", out var fn) ? fn.GetString() ?? "" : ""; + } +}