feat(adminui): isHistorized + historianTagname as first-class Tag fields
This commit is contained in:
-11
@@ -6,17 +6,6 @@
|
||||
placeholder="Reactor1.Temperature"
|
||||
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
|
||||
<div class="form-text">The AVEVA Historian tagname the driver reads against.</div></div>
|
||||
<div class="col-md-12">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" checked="@_m.IsHistorized"
|
||||
@onchange="@(e => Update(() => _m.IsHistorized = e.Value is true))" />
|
||||
<label class="form-check-label">Historized (expose OPC UA HistoryRead)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-12"><label class="form-label">Historian tagname override (optional)</label>
|
||||
<input type="text" class="form-control form-control-sm mono" value="@_m.HistorianTagname"
|
||||
@onchange="@(e => Update(() => _m.HistorianTagname = e.Value?.ToString() ?? string.Empty))" />
|
||||
<div class="form-text">Blank defaults the historian tagname to the FullName above.</div></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
-11
@@ -6,17 +6,6 @@
|
||||
placeholder="nsu=urn:server:ns;s=Line3.Temp"
|
||||
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
|
||||
<div class="form-text">The remote OPC UA NodeId the driver reads/writes/subscribes against. Use the browse picker on the driver page to find it.</div></div>
|
||||
<div class="col-md-12">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" checked="@_m.IsHistorized"
|
||||
@onchange="@(e => Update(() => _m.IsHistorized = e.Value is true))" />
|
||||
<label class="form-check-label">Historized (expose OPC UA HistoryRead)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-12"><label class="form-label">Historian tagname override (optional)</label>
|
||||
<input type="text" class="form-control form-control-sm mono" value="@_m.HistorianTagname"
|
||||
@onchange="@(e => Update(() => _m.HistorianTagname = e.Value?.ToString() ?? string.Empty))" />
|
||||
<div class="form-text">Blank defaults the historian tagname to the FullName above.</div></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -136,6 +136,39 @@
|
||||
<ValidationMessage For="@(() => _form.TagConfig)" />
|
||||
</div>
|
||||
|
||||
@* 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))
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">History</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="tag-historize"
|
||||
checked="@_historizeState.IsHistorized"
|
||||
@onchange="OnHistorizeChanged" />
|
||||
<label class="form-check-label" for="tag-historize">Historize this tag (expose OPC UA HistoryRead)</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When checked, the server serves OPC UA HistoryRead over this tag's value
|
||||
from the configured historian.
|
||||
</div>
|
||||
@if (_historizeState.IsHistorized)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<label class="form-label" for="tag-historian-tagname">Historian tagname (override, optional)</label>
|
||||
<input type="text" class="form-control form-control-sm mono" id="tag-historian-tagname"
|
||||
value="@_historizeState.HistorianTagname"
|
||||
@onchange="OnHistorianTagnameChanged" />
|
||||
<div class="form-text">Blank defaults the historian tagname to this tag's driver FullName.</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* 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<string>(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.
|
||||
|
||||
+7
-20
@@ -4,26 +4,20 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for a Wonderware (AVEVA) Historian equipment tag's TagConfig JSON. The
|
||||
/// tag binds to a historian tag by its full reference (<c>FullName</c> — the historian tagname/source
|
||||
/// the driver reads against), plus the optional driver-agnostic server-side HistoryRead intent
|
||||
/// (<c>isHistorized</c> / <c>historianTagname</c>). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
/// the driver reads against). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
/// <remarks>
|
||||
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
|
||||
/// (<c>Phase7Composer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
|
||||
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
||||
/// casing. The history keys (<c>isHistorized</c> / <c>historianTagname</c>) are camelCase to match
|
||||
/// <c>Phase7Composer.ExtractTagHistorize</c>.
|
||||
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
|
||||
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
|
||||
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
|
||||
/// </remarks>
|
||||
public sealed class HistorianWonderwareTagConfigModel
|
||||
{
|
||||
/// <summary>Historian tagname/source the tag binds to (the driver-side full reference). Required.</summary>
|
||||
public string FullName { get; set; } = "";
|
||||
|
||||
/// <summary>Whether the server exposes OPC UA HistoryRead over this tag's variable node.</summary>
|
||||
public bool IsHistorized { get; set; }
|
||||
|
||||
/// <summary>Optional historian tagname override; blank means the historian tagname defaults to <see cref="FullName"/>.</summary>
|
||||
public string HistorianTagname { get; set; } = "";
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
|
||||
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); the history keys are
|
||||
/// written camelCase and dropped when default (absent <c>isHistorized</c> ⇒ false at the composer;
|
||||
/// blank <c>historianTagname</c> ⇒ defaults to <c>FullName</c>).</summary>
|
||||
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
|
||||
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
|
||||
/// preserved unknown keys.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,27 +4,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>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 (<c>FullName</c> — the persisted
|
||||
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against),
|
||||
/// plus the optional driver-agnostic server-side HistoryRead intent (<c>isHistorized</c> /
|
||||
/// <c>historianTagname</c>). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against).
|
||||
/// Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
/// <remarks>
|
||||
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
|
||||
/// (<c>Phase7Composer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
|
||||
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
||||
/// casing. The history keys (<c>isHistorized</c> / <c>historianTagname</c>) are camelCase to match
|
||||
/// <c>Phase7Composer.ExtractTagHistorize</c>.
|
||||
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
|
||||
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
|
||||
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientTagConfigModel
|
||||
{
|
||||
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.</summary>
|
||||
public string FullName { get; set; } = "";
|
||||
|
||||
/// <summary>Whether the server exposes OPC UA HistoryRead over this tag's variable node.</summary>
|
||||
public bool IsHistorized { get; set; }
|
||||
|
||||
/// <summary>Optional historian tagname override; blank means the historian tagname defaults to <see cref="FullName"/>.</summary>
|
||||
public string HistorianTagname { get; set; } = "";
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
|
||||
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); the history keys are
|
||||
/// written camelCase and dropped when default (absent <c>isHistorized</c> ⇒ false at the composer;
|
||||
/// blank <c>historianTagname</c> ⇒ defaults to <c>FullName</c>).</summary>
|
||||
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
|
||||
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
|
||||
/// preserved unknown keys.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Pure, driver-agnostic merge helper for the two server-side HistoryRead intent keys at the ROOT of a
|
||||
/// tag's <c>TagConfig</c> JSON: <c>isHistorized</c> (camelCase bool — omit/false default) and
|
||||
/// <c>historianTagname</c> (camelCase string override — omit when blank). These map to what the server's
|
||||
/// <c>Phase7Composer.ExtractTagHistorize</c> reads (see <c>docs/Historian.md</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <see cref="TagConfigJson.ParseOrNew"/> / <see cref="TagConfigJson.Set"/>), so neither clobbers the
|
||||
/// other's fields. These keys are intentionally NOT carried inside any per-driver typed model.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TagHistorizeConfig
|
||||
{
|
||||
/// <summary>The two history-intent values parsed from a TagConfig JSON's root.</summary>
|
||||
/// <param name="IsHistorized">Whether the server exposes OPC UA HistoryRead over this tag's node.</param>
|
||||
/// <param name="HistorianTagname">Optional historian tagname override; <c>""</c> means default to the tag's FullName.</param>
|
||||
public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname);
|
||||
|
||||
/// <summary>Reads the history-intent keys from a TagConfig JSON, defaulting any absent key
|
||||
/// (null/blank/malformed input ⇒ <c>(false, "")</c>).</summary>
|
||||
public static HistorizeState Read(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new HistorizeState(
|
||||
TagConfigJson.GetBool(o, "isHistorized"),
|
||||
TagConfigJson.GetString(o, "historianTagname") ?? "");
|
||||
}
|
||||
|
||||
/// <summary>Merges the two history-intent keys into <paramref name="json"/>, preserving every other
|
||||
/// (driver-specific or unknown) key. <c>isHistorized</c> is dropped when false (absent ⇒ false at the
|
||||
/// composer); <c>historianTagname</c> is dropped when null/blank (absent ⇒ defaults to FullName) and
|
||||
/// trimmed otherwise.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
+21
-35
@@ -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()
|
||||
{
|
||||
|
||||
+20
-34
@@ -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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the driver-agnostic <see cref="TagHistorizeConfig"/> merge helper — the pure seam the
|
||||
/// TagModal uses to read/write the root <c>isHistorized</c> / <c>historianTagname</c> 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.
|
||||
/// </summary>
|
||||
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("}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user