feat(adminui): typed TagConfig editors for OpcUaClient + Historian

This commit is contained in:
Joseph Doherty
2026-06-16 16:25:19 -04:00
parent 39755d717d
commit 72d414ada7
11 changed files with 483 additions and 1 deletions
@@ -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();