feat(adminui): typed TagConfig editors for OpcUaClient + Historian
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-12"><label class="form-label">Historian tagname (FullName)</label>
|
||||
<input type="text" class="form-control form-control-sm mono" value="@_m.FullName"
|
||||
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 {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
private HistorianWonderwareTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = HistorianWonderwareTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-12"><label class="form-label">Upstream node reference (FullName)</label>
|
||||
<input type="text" class="form-control form-control-sm mono" value="@_m.FullName"
|
||||
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 {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
private OpcUaClientTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = OpcUaClientTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
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>
|
||||
/// <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>.
|
||||
/// </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
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
/// <param name="json">The tag's TagConfig JSON (null/blank/malformed ⇒ defaults).</param>
|
||||
public static HistorianWonderwareTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
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>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(FullName) ? "A historian tagname (FullName) is required." : null;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
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>
|
||||
/// <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>.
|
||||
/// </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
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
/// <param name="json">The tag's TagConfig JSON (null/blank/malformed ⇒ defaults).</param>
|
||||
public static OpcUaClientTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
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>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(FullName) ? "An upstream node reference (FullName) is required." : null;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ public static class TagConfigEditorMap
|
||||
["AbLegacy"] = typeof(Components.Shared.Uns.TagEditors.AbLegacyTagConfigEditor),
|
||||
["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
|
||||
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||
["OpcUaClient"] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||
["Historian.Wonderware"] = typeof(Components.Shared.Uns.TagEditors.HistorianWonderwareTagConfigEditor),
|
||||
};
|
||||
|
||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||
|
||||
@@ -29,6 +29,10 @@ public static class TagConfigJson
|
||||
public static int GetInt(JsonObject o, string name, int fallback = 0)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i) ? i : fallback;
|
||||
|
||||
/// <summary>Reads a bool value, or <paramref name="fallback"/> if absent/null/non-boolean (incl. object/array/number/string nodes).</summary>
|
||||
public static bool GetBool(JsonObject o, string name, bool fallback = false)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<bool>(out var b) ? b : fallback;
|
||||
|
||||
/// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
|
||||
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
|
||||
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
|
||||
|
||||
@@ -18,6 +18,8 @@ public static class TagConfigValidator
|
||||
["AbLegacy"] = j => AbLegacyTagConfigModel.FromJson(j).Validate(),
|
||||
["TwinCat"] = j => TwinCATTagConfigModel.FromJson(j).Validate(),
|
||||
["Focas"] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
||||
["OpcUaClient"] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
||||
["Historian.Wonderware"] = j => HistorianWonderwareTagConfigModel.FromJson(j).Validate(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class HistorianWonderwareTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = HistorianWonderwareTagConfigModel.FromJson(json);
|
||||
|
||||
m.FullName.ShouldBe("");
|
||||
m.IsHistorized.ShouldBeFalse();
|
||||
m.HistorianTagname.ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_reads_all_fields()
|
||||
{
|
||||
var m = HistorianWonderwareTagConfigModel.FromJson(
|
||||
"""{"FullName":"SysTimeSec","isHistorized":true,"historianTagname":"Reactor1.Temp"}""");
|
||||
|
||||
m.FullName.ShouldBe("SysTimeSec");
|
||||
m.IsHistorized.ShouldBeTrue();
|
||||
m.HistorianTagname.ShouldBe("Reactor1.Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_all_fields()
|
||||
{
|
||||
var m = new HistorianWonderwareTagConfigModel
|
||||
{
|
||||
FullName = "Reactor1.Temp",
|
||||
IsHistorized = true,
|
||||
HistorianTagname = "Reactor1.Temp.Override",
|
||||
};
|
||||
|
||||
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()
|
||||
{
|
||||
var m = new HistorianWonderwareTagConfigModel
|
||||
{
|
||||
FullName = "Reactor1.Temp",
|
||||
IsHistorized = true,
|
||||
HistorianTagname = "Reactor1.Temp.Override",
|
||||
};
|
||||
|
||||
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]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = HistorianWonderwareTagConfigModel
|
||||
.FromJson("""{"FullName":"Reactor1.Temp","deadband":0.5}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("deadband");
|
||||
json.ShouldContain("0.5");
|
||||
// and the exposed field still round-trips
|
||||
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_trims_FullName()
|
||||
{
|
||||
var json = new HistorianWonderwareTagConfigModel { FullName = " Reactor1.Temp " }.ToJson();
|
||||
|
||||
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_error_when_FullName_blank()
|
||||
{
|
||||
new HistorianWonderwareTagConfigModel().Validate().ShouldNotBeNullOrEmpty();
|
||||
new HistorianWonderwareTagConfigModel { FullName = " " }.Validate().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_FullName_present()
|
||||
{
|
||||
new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" }.Validate().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class OpcUaClientTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = OpcUaClientTagConfigModel.FromJson(json);
|
||||
|
||||
m.FullName.ShouldBe("");
|
||||
m.IsHistorized.ShouldBeFalse();
|
||||
m.HistorianTagname.ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_reads_all_fields()
|
||||
{
|
||||
var m = OpcUaClientTagConfigModel.FromJson(
|
||||
"""{"FullName":"nsu=urn:srv;s=Line3.Temp","isHistorized":true,"historianTagname":"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()
|
||||
{
|
||||
var m = new OpcUaClientTagConfigModel
|
||||
{
|
||||
FullName = "ns=2;s=Line3.Temp",
|
||||
IsHistorized = true,
|
||||
HistorianTagname = "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()
|
||||
{
|
||||
var m = new OpcUaClientTagConfigModel
|
||||
{
|
||||
FullName = "ns=2;s=Line3.Temp",
|
||||
IsHistorized = true,
|
||||
HistorianTagname = "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]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = OpcUaClientTagConfigModel
|
||||
.FromJson("""{"FullName":"ns=2;s=X","samplingIntervalMs":250}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("samplingIntervalMs");
|
||||
json.ShouldContain("250");
|
||||
// and the exposed field still round-trips
|
||||
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_trims_FullName()
|
||||
{
|
||||
var json = new OpcUaClientTagConfigModel { FullName = " ns=2;s=X " }.ToJson();
|
||||
|
||||
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_error_when_FullName_blank()
|
||||
{
|
||||
new OpcUaClientTagConfigModel().Validate().ShouldNotBeNullOrEmpty();
|
||||
new OpcUaClientTagConfigModel { FullName = " " }.Validate().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_FullName_present()
|
||||
{
|
||||
new OpcUaClientTagConfigModel { FullName = "ns=2;s=X" }.Validate().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,31 @@ public sealed class TagConfigJsonTests
|
||||
TagConfigJson.GetString(obj, "nested").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBool_reads_a_bool()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"isHistorized":true}""");
|
||||
|
||||
TagConfigJson.GetBool(obj, "isHistorized").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBool_falls_back_when_absent()
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
TagConfigJson.GetBool(obj, "isHistorized", fallback: true).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBool_falls_back_when_value_is_not_a_bool()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"isHistorized":"yes","other":1}""");
|
||||
|
||||
TagConfigJson.GetBool(obj, "isHistorized").ShouldBeFalse();
|
||||
TagConfigJson.GetBool(obj, "other").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnum_parses_by_name_case_insensitive()
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class TagConfigValidatorTests
|
||||
|
||||
[Fact]
|
||||
public void Unmapped_driver_has_no_typed_validator_so_is_valid()
|
||||
=> TagConfigValidator.Validate("OpcUaClient", "{}").ShouldBeNull();
|
||||
=> TagConfigValidator.Validate("GalaxyMxGateway", "{}").ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Modbus_has_no_required_field_so_empty_config_is_valid()
|
||||
@@ -25,12 +25,22 @@ public sealed class TagConfigValidatorTests
|
||||
[InlineData("AbLegacy")]
|
||||
[InlineData("TwinCat")]
|
||||
[InlineData("Focas")]
|
||||
[InlineData("OpcUaClient")]
|
||||
[InlineData("Historian.Wonderware")]
|
||||
public void Required_field_blank_is_rejected(string driverType)
|
||||
{
|
||||
TagConfigValidator.Validate(driverType, "{}").ShouldNotBeNullOrEmpty();
|
||||
TagConfigValidator.Validate(driverType, null).ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUaClient_with_full_name_is_valid()
|
||||
=> TagConfigValidator.Validate("OpcUaClient", """{"FullName":"ns=2;s=Line3.Temp"}""").ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void HistorianWonderware_with_full_name_is_valid()
|
||||
=> TagConfigValidator.Validate("Historian.Wonderware", """{"FullName":"Reactor1.Temp"}""").ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void S7_with_address_is_valid()
|
||||
=> TagConfigValidator.Validate("S7", """{"address":"DB1.DBW0"}""").ShouldBeNull();
|
||||
|
||||
Reference in New Issue
Block a user