Blank defaults the historian tagname to the FullName above.
@code {
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 fedd735e..b791f75c 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
@@ -136,6 +136,39 @@
+ @* Driver-agnostic server-side HistoryRead intent. Distinct from the native-alarm
+ "Historize to AVEVA" toggle below: THIS gates TAG-VALUE history (root keys
+ `isHistorized` / `historianTagname`, read by Phase7Composer.ExtractTagHistorize),
+ merged onto the canonical TagConfig via the pure TagHistorizeConfig seam so it
+ composes with the typed editor's driver-specific fields (both preserve unknown keys).
+ Shown for EVERY driver once one is picked. *@
+ @if (!string.IsNullOrEmpty(_form.DriverInstanceId))
+ {
+
+
+
+
+
+
+
+ When checked, the server serves OPC UA HistoryRead over this tag's value
+ from the configured historian.
+
+ @if (_historizeState.IsHistorized)
+ {
+
+
+
+
Blank defaults the historian tagname to this tag's driver FullName.
+
+ }
+
+ }
+
@* 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;
@@ -211,6 +244,11 @@
private bool _showGalaxyPicker;
private string _galaxyAddress = "";
+ // Driver-agnostic server-side HistoryRead intent (root `isHistorized` / `historianTagname`), reflected
+ // for the "Historize this tag" 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 TagHistorizeConfig.
+ private TagHistorizeConfig.HistorizeState _historizeState;
+
// 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;
@@ -238,6 +276,8 @@
_form.TagConfig = "{}";
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
_galaxyAddress = "";
+ // The reset TagConfig carries no history intent — reflect that in the historize controls.
+ _historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
@@ -255,14 +295,32 @@
["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v),
};
+ // Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
+ // properties below don't each re-parse the JSON on every render. Re-parsed only when TagConfig changes.
+ private NativeAlarmModel _nativeAlarm = NativeAlarmModel.FromJson("{}");
+ private string? _nativeAlarmSource;
+
+ private NativeAlarmModel NativeAlarm
+ {
+ get
+ {
+ if (_nativeAlarmSource != _form.TagConfig)
+ {
+ _nativeAlarmSource = _form.TagConfig;
+ _nativeAlarm = NativeAlarmModel.FromJson(_form.TagConfig);
+ }
+ return _nativeAlarm;
+ }
+ }
+
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
- private bool HasNativeAlarm => NativeAlarmModel.FromJson(_form.TagConfig).IsAlarm;
+ private bool HasNativeAlarm => NativeAlarm.IsAlarm;
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
// explicit false leaves it unchecked.
- private bool AlarmHistorizeToAveva => NativeAlarmModel.FromJson(_form.TagConfig).HistorizeToAveva != false;
+ private bool AlarmHistorizeToAveva => NativeAlarm.HistorizeToAveva != false;
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
@@ -271,11 +329,28 @@
{
var model = NativeAlarmModel.FromJson(_form.TagConfig);
if (!model.IsAlarm) { return; }
- var isChecked = e.Value is bool b && b;
- model.HistorizeToAveva = isChecked ? null : false;
+ model.HistorizeToAveva = e.Value is true ? null : false;
_form.TagConfig = model.ToJson();
}
+ // Toggle the root `isHistorized` tag-value history intent in the raw TagConfig, merged via the pure
+ // TagHistorizeConfig seam so the typed editor's driver-specific keys are preserved. Distinct from the
+ // native-alarm "Historize to AVEVA" opt-out above (that gates alarm-transition history, not tag values).
+ private void OnHistorizeChanged(ChangeEventArgs e)
+ {
+ var isHistorized = e.Value is true;
+ _form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, isHistorized, _historizeState.HistorianTagname);
+ _historizeState = TagHistorizeConfig.Read(_form.TagConfig);
+ }
+
+ // Merge the optional historian-tagname override (root `historianTagname`) into the raw TagConfig.
+ private void OnHistorianTagnameChanged(ChangeEventArgs e)
+ {
+ var tagname = e.Value?.ToString() ?? string.Empty;
+ _form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, _historizeState.IsHistorized, tagname);
+ _historizeState = TagHistorizeConfig.Read(_form.TagConfig);
+ }
+
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
@@ -301,6 +376,8 @@
_showGalaxyPicker = false;
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
_galaxyAddress = ReadFullName(_form.TagConfig);
+ // Seed the historize controls from any existing root isHistorized/historianTagname keys.
+ _historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HistorianWonderwareTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HistorianWonderwareTagConfigModel.cs
index 68dc6054..765061b6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HistorianWonderwareTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HistorianWonderwareTagConfigModel.cs
@@ -4,26 +4,20 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// Typed working model for a Wonderware (AVEVA) Historian equipment tag's TagConfig JSON. The
/// tag binds to a historian tag by its full reference (FullName — the historian tagname/source
-/// the driver reads against), plus the optional driver-agnostic server-side HistoryRead intent
-/// (isHistorized / historianTagname). Preserves unrecognised JSON keys across a load→save.
+/// the driver reads against). Preserves unrecognised JSON keys across a load→save.
///
/// The FullName key is intentionally PascalCase: the deploy-time composer + node walker
/// (Phase7Composer.ExtractTagFullName, EquipmentNodeWalker) read it via a
/// case-sensitive TryGetProperty("FullName", …), so the editor MUST persist that exact
-/// casing. The history keys (isHistorized / historianTagname) are camelCase to match
-/// Phase7Composer.ExtractTagHistorize.
+/// casing. The driver-agnostic server-side HistoryRead intent keys (isHistorized /
+/// historianTagname) are NOT modelled here — they are owned by the TagModal-merge seam
+/// () and survive a load→save of this model as preserved unknown keys.
///
public sealed class HistorianWonderwareTagConfigModel
{
/// Historian tagname/source the tag binds to (the driver-side full reference). Required.
public string FullName { get; set; } = "";
- /// Whether the server exposes OPC UA HistoryRead over this tag's variable node.
- public bool IsHistorized { get; set; }
-
- /// Optional historian tagname override; blank means the historian tagname defaults to .
- public string HistorianTagname { get; set; } = "";
-
private JsonObject _bag = new();
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
@@ -35,24 +29,17 @@ public sealed class HistorianWonderwareTagConfigModel
return new HistorianWonderwareTagConfigModel
{
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
- IsHistorized = TagConfigJson.GetBool(o, "isHistorized"),
- HistorianTagname = TagConfigJson.GetString(o, "historianTagname") ?? "",
_bag = o,
};
}
/// Serialises this model back to a TagConfig JSON string over the preserved key bag.
- /// FullName is written PascalCase (the composer/walker contract key); the history keys are
- /// written camelCase and dropped when default (absent isHistorized ⇒ false at the composer;
- /// blank historianTagname ⇒ defaults to FullName).
+ /// FullName is written PascalCase (the composer/walker contract key); any history keys merged
+ /// by the TagModal (isHistorized / historianTagname) are carried through untouched as
+ /// preserved unknown keys.
public string ToJson()
{
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
- // Drop isHistorized when false so the persisted blob stays minimal and matches the
- // composer's "absent ⇒ false" convention; same for a blank historianTagname override.
- TagConfigJson.Set(_bag, "isHistorized", IsHistorized ? true : null);
- TagConfigJson.Set(_bag, "historianTagname",
- string.IsNullOrWhiteSpace(HistorianTagname) ? null : HistorianTagname.Trim());
return TagConfigJson.Serialize(_bag);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs
index ef65da78..c072d731 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs
@@ -4,27 +4,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// Typed working model for an OPC UA Client (gateway) equipment tag's TagConfig JSON. The tag
/// is bound to the upstream OPC UA server node by its full reference (FullName — the persisted
-/// stable nsu=…;s=… or plain ns=2;s=… NodeId the driver reads/writes/subscribes against),
-/// plus the optional driver-agnostic server-side HistoryRead intent (isHistorized /
-/// historianTagname). Preserves unrecognised JSON keys across a load→save.
+/// stable nsu=…;s=… or plain ns=2;s=… NodeId the driver reads/writes/subscribes against).
+/// Preserves unrecognised JSON keys across a load→save.
///
/// The FullName key is intentionally PascalCase: the deploy-time composer + node walker
/// (Phase7Composer.ExtractTagFullName, EquipmentNodeWalker) read it via a
/// case-sensitive TryGetProperty("FullName", …), so the editor MUST persist that exact
-/// casing. The history keys (isHistorized / historianTagname) are camelCase to match
-/// Phase7Composer.ExtractTagHistorize.
+/// casing. The driver-agnostic server-side HistoryRead intent keys (isHistorized /
+/// historianTagname) are NOT modelled here — they are owned by the TagModal-merge seam
+/// () and survive a load→save of this model as preserved unknown keys.
///
public sealed class OpcUaClientTagConfigModel
{
/// Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.
public string FullName { get; set; } = "";
- /// Whether the server exposes OPC UA HistoryRead over this tag's variable node.
- public bool IsHistorized { get; set; }
-
- /// Optional historian tagname override; blank means the historian tagname defaults to .
- public string HistorianTagname { get; set; } = "";
-
private JsonObject _bag = new();
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
@@ -36,24 +30,17 @@ public sealed class OpcUaClientTagConfigModel
return new OpcUaClientTagConfigModel
{
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
- IsHistorized = TagConfigJson.GetBool(o, "isHistorized"),
- HistorianTagname = TagConfigJson.GetString(o, "historianTagname") ?? "",
_bag = o,
};
}
/// Serialises this model back to a TagConfig JSON string over the preserved key bag.
- /// FullName is written PascalCase (the composer/walker contract key); the history keys are
- /// written camelCase and dropped when default (absent isHistorized ⇒ false at the composer;
- /// blank historianTagname ⇒ defaults to FullName).
+ /// FullName is written PascalCase (the composer/walker contract key); any history keys merged
+ /// by the TagModal (isHistorized / historianTagname) are carried through untouched as
+ /// preserved unknown keys.
public string ToJson()
{
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
- // Drop isHistorized when false so the persisted blob stays minimal and matches the
- // composer's "absent ⇒ false" convention; same for a blank historianTagname override.
- TagConfigJson.Set(_bag, "isHistorized", IsHistorized ? true : null);
- TagConfigJson.Set(_bag, "historianTagname",
- string.IsNullOrWhiteSpace(HistorianTagname) ? null : HistorianTagname.Trim());
return TagConfigJson.Serialize(_bag);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagHistorizeConfig.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagHistorizeConfig.cs
new file mode 100644
index 00000000..586a44fd
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagHistorizeConfig.cs
@@ -0,0 +1,47 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+
+///
+/// Pure, driver-agnostic merge helper for the two server-side HistoryRead intent keys at the ROOT of a
+/// tag's TagConfig JSON: isHistorized (camelCase bool — omit/false default) and
+/// historianTagname (camelCase string override — omit when blank). These map to what the server's
+/// Phase7Composer.ExtractTagHistorize reads (see docs/Historian.md).
+///
+///
+/// This is the TagModal-merge seam: 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 "Historize this tag"
+/// checkbox + "Historian tagname (override)" textbox 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 TagHistorizeConfig
+{
+ /// The two history-intent values parsed from a TagConfig JSON's root.
+ /// Whether the server exposes OPC UA HistoryRead over this tag's node.
+ /// Optional historian tagname override; "" means default to the tag's FullName.
+ public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname);
+
+ /// Reads the history-intent keys from a TagConfig JSON, defaulting any absent key
+ /// (null/blank/malformed input ⇒ (false, "")).
+ public static HistorizeState Read(string? json)
+ {
+ var o = TagConfigJson.ParseOrNew(json);
+ return new HistorizeState(
+ TagConfigJson.GetBool(o, "isHistorized"),
+ TagConfigJson.GetString(o, "historianTagname") ?? "");
+ }
+
+ /// Merges the two history-intent keys into , preserving every other
+ /// (driver-specific or unknown) key. isHistorized is dropped when false (absent ⇒ false at the
+ /// composer); historianTagname is dropped when null/blank (absent ⇒ defaults to FullName) and
+ /// trimmed otherwise.
+ public static string Set(string? json, bool isHistorized, string? historianTagname)
+ {
+ var o = TagConfigJson.ParseOrNew(json);
+ TagConfigJson.Set(o, "isHistorized", isHistorized ? true : null);
+ TagConfigJson.Set(o, "historianTagname",
+ string.IsNullOrWhiteSpace(historianTagname) ? null : historianTagname.Trim());
+ return TagConfigJson.Serialize(o);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HistorianWonderwareTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HistorianWonderwareTagConfigModelTests.cs
index 8d976d65..2428c501 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HistorianWonderwareTagConfigModelTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HistorianWonderwareTagConfigModelTests.cs
@@ -16,66 +16,38 @@ public sealed class HistorianWonderwareTagConfigModelTests
var m = HistorianWonderwareTagConfigModel.FromJson(json);
m.FullName.ShouldBe("");
- m.IsHistorized.ShouldBeFalse();
- m.HistorianTagname.ShouldBe("");
}
[Fact]
- public void FromJson_reads_all_fields()
+ public void FromJson_reads_FullName()
{
var m = HistorianWonderwareTagConfigModel.FromJson(
- """{"FullName":"SysTimeSec","isHistorized":true,"historianTagname":"Reactor1.Temp"}""");
+ """{"FullName":"Reactor1.Temp"}""");
- m.FullName.ShouldBe("SysTimeSec");
- m.IsHistorized.ShouldBeTrue();
- m.HistorianTagname.ShouldBe("Reactor1.Temp");
+ m.FullName.ShouldBe("Reactor1.Temp");
}
[Fact]
- public void Round_trip_preserves_all_fields()
+ public void Round_trip_preserves_FullName()
{
- var m = new HistorianWonderwareTagConfigModel
- {
- FullName = "Reactor1.Temp",
- IsHistorized = true,
- HistorianTagname = "Reactor1.Temp.Override",
- };
+ var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" };
var json = m.ToJson();
var m2 = HistorianWonderwareTagConfigModel.FromJson(json);
m2.FullName.ShouldBe("Reactor1.Temp");
- m2.IsHistorized.ShouldBeTrue();
- m2.HistorianTagname.ShouldBe("Reactor1.Temp.Override");
}
[Fact]
- public void ToJson_emits_PascalCase_FullName_and_camelCase_history_keys()
+ public void ToJson_emits_PascalCase_FullName()
{
- var m = new HistorianWonderwareTagConfigModel
- {
- FullName = "Reactor1.Temp",
- IsHistorized = true,
- HistorianTagname = "Reactor1.Temp.Override",
- };
+ var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" };
var json = m.ToJson();
// FullName is the composer/walker contract key — PascalCase, case-sensitive.
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
json.ShouldNotContain("\"fullName\"", Case.Sensitive);
- json.ShouldContain("\"isHistorized\":true");
- json.ShouldContain("\"historianTagname\":\"Reactor1.Temp.Override\"");
- }
-
- [Fact]
- public void ToJson_omits_history_keys_when_default()
- {
- var json = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" }.ToJson();
-
- json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
- json.ShouldNotContain("isHistorized");
- json.ShouldNotContain("historianTagname");
}
[Fact]
@@ -91,6 +63,20 @@ public sealed class HistorianWonderwareTagConfigModelTests
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
}
+ [Fact]
+ public void FromJson_then_ToJson_preserves_TagModal_merged_history_keys()
+ {
+ // The TagModal-merge seam writes isHistorized/historianTagname at the TagConfig root; this model
+ // does NOT model them, so they must survive a load→save untouched as preserved unknown keys.
+ var json = HistorianWonderwareTagConfigModel
+ .FromJson("""{"FullName":"Reactor1.Temp","isHistorized":true,"historianTagname":"Reactor1.Temp.Override"}""")
+ .ToJson();
+
+ json.ShouldContain("\"isHistorized\":true");
+ json.ShouldContain("\"historianTagname\":\"Reactor1.Temp.Override\"");
+ json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
+ }
+
[Fact]
public void ToJson_trims_FullName()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/OpcUaClientTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/OpcUaClientTagConfigModelTests.cs
index 6d75d995..05ffcf02 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/OpcUaClientTagConfigModelTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/OpcUaClientTagConfigModelTests.cs
@@ -16,66 +16,38 @@ public sealed class OpcUaClientTagConfigModelTests
var m = OpcUaClientTagConfigModel.FromJson(json);
m.FullName.ShouldBe("");
- m.IsHistorized.ShouldBeFalse();
- m.HistorianTagname.ShouldBe("");
}
[Fact]
- public void FromJson_reads_all_fields()
+ public void FromJson_reads_FullName()
{
var m = OpcUaClientTagConfigModel.FromJson(
- """{"FullName":"nsu=urn:srv;s=Line3.Temp","isHistorized":true,"historianTagname":"Line3_Temp"}""");
+ """{"FullName":"nsu=urn:srv;s=Line3.Temp"}""");
m.FullName.ShouldBe("nsu=urn:srv;s=Line3.Temp");
- m.IsHistorized.ShouldBeTrue();
- m.HistorianTagname.ShouldBe("Line3_Temp");
}
[Fact]
- public void Round_trip_preserves_all_fields()
+ public void Round_trip_preserves_FullName()
{
- var m = new OpcUaClientTagConfigModel
- {
- FullName = "ns=2;s=Line3.Temp",
- IsHistorized = true,
- HistorianTagname = "Line3_Temp",
- };
+ var m = new OpcUaClientTagConfigModel { FullName = "ns=2;s=Line3.Temp" };
var json = m.ToJson();
var m2 = OpcUaClientTagConfigModel.FromJson(json);
m2.FullName.ShouldBe("ns=2;s=Line3.Temp");
- m2.IsHistorized.ShouldBeTrue();
- m2.HistorianTagname.ShouldBe("Line3_Temp");
}
[Fact]
- public void ToJson_emits_PascalCase_FullName_and_camelCase_history_keys()
+ public void ToJson_emits_PascalCase_FullName()
{
- var m = new OpcUaClientTagConfigModel
- {
- FullName = "ns=2;s=Line3.Temp",
- IsHistorized = true,
- HistorianTagname = "Line3_Temp",
- };
+ var m = new OpcUaClientTagConfigModel { FullName = "ns=2;s=Line3.Temp" };
var json = m.ToJson();
// FullName is the composer/walker contract key — PascalCase, case-sensitive.
json.ShouldContain("\"FullName\":\"ns=2;s=Line3.Temp\"");
json.ShouldNotContain("\"fullName\"", Case.Sensitive);
- json.ShouldContain("\"isHistorized\":true");
- json.ShouldContain("\"historianTagname\":\"Line3_Temp\"");
- }
-
- [Fact]
- public void ToJson_omits_history_keys_when_default()
- {
- var json = new OpcUaClientTagConfigModel { FullName = "ns=2;s=X" }.ToJson();
-
- json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
- json.ShouldNotContain("isHistorized");
- json.ShouldNotContain("historianTagname");
}
[Fact]
@@ -91,6 +63,20 @@ public sealed class OpcUaClientTagConfigModelTests
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
}
+ [Fact]
+ public void FromJson_then_ToJson_preserves_TagModal_merged_history_keys()
+ {
+ // The TagModal-merge seam writes isHistorized/historianTagname at the TagConfig root; this model
+ // does NOT model them, so they must survive a load→save untouched as preserved unknown keys.
+ var json = OpcUaClientTagConfigModel
+ .FromJson("""{"FullName":"ns=2;s=X","isHistorized":true,"historianTagname":"Line3_Temp"}""")
+ .ToJson();
+
+ json.ShouldContain("\"isHistorized\":true");
+ json.ShouldContain("\"historianTagname\":\"Line3_Temp\"");
+ json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
+ }
+
[Fact]
public void ToJson_trims_FullName()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs
new file mode 100644
index 00000000..2f6628a4
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs
@@ -0,0 +1,126 @@
+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 isHistorized / historianTagname history 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.
+///
+public sealed class TagHistorizeConfigTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("{}")]
+ [InlineData("not json")]
+ public void Read_returns_defaults_for_empty_or_malformed(string? json)
+ {
+ var h = TagHistorizeConfig.Read(json);
+
+ h.IsHistorized.ShouldBeFalse();
+ h.HistorianTagname.ShouldBe("");
+ }
+
+ [Fact]
+ public void Read_reads_both_keys()
+ {
+ var h = TagHistorizeConfig.Read("""{"FullName":"ns=2;s=X","isHistorized":true,"historianTagname":"Line3_Temp"}""");
+
+ h.IsHistorized.ShouldBeTrue();
+ h.HistorianTagname.ShouldBe("Line3_Temp");
+ }
+
+ [Fact]
+ public void Set_true_with_tagname_writes_both_camelCase_keys()
+ {
+ var json = TagHistorizeConfig.Set("""{"FullName":"ns=2;s=X"}""", isHistorized: true, historianTagname: "Line3_Temp");
+
+ json.ShouldContain("\"isHistorized\":true");
+ json.ShouldContain("\"historianTagname\":\"Line3_Temp\"");
+ // The driver field is untouched.
+ json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
+ }
+
+ [Fact]
+ public void Set_true_round_trips_through_Read()
+ {
+ var json = TagHistorizeConfig.Set("{}", isHistorized: true, historianTagname: "Reactor1.Temp");
+
+ var h = TagHistorizeConfig.Read(json);
+ h.IsHistorized.ShouldBeTrue();
+ h.HistorianTagname.ShouldBe("Reactor1.Temp");
+ }
+
+ [Fact]
+ public void Set_false_removes_isHistorized_key()
+ {
+ var json = TagHistorizeConfig.Set(
+ """{"FullName":"ns=2;s=X","isHistorized":true,"historianTagname":"Line3_Temp"}""",
+ isHistorized: false, historianTagname: "Line3_Temp");
+
+ json.ShouldNotContain("isHistorized");
+ json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
+ }
+
+ [Fact]
+ public void Set_blank_tagname_removes_historianTagname_key()
+ {
+ var json = TagHistorizeConfig.Set(
+ """{"isHistorized":true,"historianTagname":"Old"}""",
+ isHistorized: true, historianTagname: " ");
+
+ json.ShouldContain("\"isHistorized\":true");
+ json.ShouldNotContain("historianTagname");
+ }
+
+ [Fact]
+ public void Set_null_tagname_removes_historianTagname_key()
+ {
+ var json = TagHistorizeConfig.Set(
+ """{"isHistorized":true,"historianTagname":"Old"}""",
+ isHistorized: true, historianTagname: null);
+
+ json.ShouldContain("\"isHistorized\":true");
+ json.ShouldNotContain("historianTagname");
+ }
+
+ [Fact]
+ public void Set_trims_historianTagname()
+ {
+ var json = TagHistorizeConfig.Set("{}", isHistorized: true, historianTagname: " Line3_Temp ");
+
+ json.ShouldContain("\"historianTagname\":\"Line3_Temp\"");
+ }
+
+ [Fact]
+ public void Set_preserves_driver_specific_unknown_keys()
+ {
+ var json = TagHistorizeConfig.Set(
+ """{"register":40001,"scale":0.1,"nested":{"a":1}}""",
+ isHistorized: true, historianTagname: "T");
+
+ json.ShouldContain("register");
+ json.ShouldContain("40001");
+ json.ShouldContain("scale");
+ json.ShouldContain("0.1");
+ json.ShouldContain("nested");
+ json.ShouldContain("\"isHistorized\":true");
+ json.ShouldContain("\"historianTagname\":\"T\"");
+ }
+
+ [Fact]
+ public void Set_on_malformed_input_starts_from_empty_object()
+ {
+ var json = TagHistorizeConfig.Set("not json", isHistorized: true, historianTagname: null);
+
+ json.ShouldContain("\"isHistorized\":true");
+ // No crash, valid object emitted.
+ json.ShouldStartWith("{");
+ json.ShouldEndWith("}");
+ }
+}