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() ?? "" : "";
+ }
+}