From c00a5471912189688938fb1dda405924e5888add Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 16:44:46 -0400 Subject: [PATCH] feat(adminui): isHistorized + historianTagname as first-class Tag fields --- .../HistorianWonderwareTagConfigEditor.razor | 11 -- .../OpcUaClientTagConfigEditor.razor | 11 -- .../Components/Shared/Uns/TagModal.razor | 85 +++++++++++- .../HistorianWonderwareTagConfigModel.cs | 27 +--- .../TagEditors/OpcUaClientTagConfigModel.cs | 29 ++-- .../Uns/TagEditors/TagHistorizeConfig.cs | 47 +++++++ .../HistorianWonderwareTagConfigModelTests.cs | 56 +++----- .../Uns/OpcUaClientTagConfigModelTests.cs | 54 +++----- .../Uns/TagHistorizeConfigTests.cs | 126 ++++++++++++++++++ 9 files changed, 310 insertions(+), 136 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagHistorizeConfig.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HistorianWonderwareTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HistorianWonderwareTagConfigEditor.razor index 8a5ee6eb..508a3082 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HistorianWonderwareTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HistorianWonderwareTagConfigEditor.razor @@ -6,17 +6,6 @@ placeholder="Reactor1.Temperature" @onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
The AVEVA Historian tagname the driver reads against.
-
-
- - -
-
-
- -
Blank defaults the historian tagname to the FullName above.
@code { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor index 1a1721b9..58ea6ade 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor @@ -6,17 +6,6 @@ placeholder="nsu=urn:server:ns;s=Line3.Temp" @onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
The remote OPC UA NodeId the driver reads/writes/subscribes against. Use the browse picker on the driver page to find it.
-
-
- - -
-
-
- -
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("}"); + } +}