feat(adminui): isHistorized + historianTagname as first-class Tag fields

This commit is contained in:
Joseph Doherty
2026-06-16 16:44:46 -04:00
parent c98625fd9f
commit c00a547191
9 changed files with 310 additions and 136 deletions
@@ -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()
{
@@ -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("}");
}
}