feat(commons): TagConfigIntent.Parse — single home for the byte-parity TagConfig intents (R2-11, 01/C-1)

This commit is contained in:
Joseph Doherty
2026-07-13 10:39:07 -04:00
parent 353f04b70c
commit cd3f1270ee
3 changed files with 258 additions and 1 deletions
@@ -0,0 +1,128 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// The single byte-parity authority for the cross-driver TagConfig platform intents (R2-11, 01/C-1).
/// Ports the three retired <c>OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests</c> tables and
/// adds the <see cref="TagConfigIntent.FullName"/> / <see cref="TagConfigIntent.ExplicitFullName"/>
/// distinction plus the never-throws property.
/// </summary>
public sealed class TagConfigIntentTests
{
// ---- FullName / ExplicitFullName ----
[Theory]
[InlineData("{\"FullName\":\"X.Y\"}", "X.Y", "X.Y")]
[InlineData("{\"FullName\":\" \"}", " ", " ")] // whitespace string returned verbatim (not trimmed)
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", "A.B", "A.B")]
// fallbacks: FullName = raw blob, ExplicitFullName = null
[InlineData("{\"region\":\"HoldingRegisters\"}", "{\"region\":\"HoldingRegisters\"}", null)]
[InlineData("{\"FullName\":123}", "{\"FullName\":123}", null)]
[InlineData("[1,2]", "[1,2]", null)]
[InlineData("not json {", "not json {", null)]
[InlineData(" ", " ", null)]
public void FullName_and_ExplicitFullName(string cfg, string expectedFullName, string? expectedExplicit)
{
var intent = TagConfigIntent.Parse(cfg);
intent.FullName.ShouldBe(expectedFullName);
intent.ExplicitFullName.ShouldBe(expectedExplicit);
}
[Fact]
public void Parse_null_yields_empty_FullName_and_null_explicit()
{
var intent = TagConfigIntent.Parse(null);
intent.FullName.ShouldBe(string.Empty);
intent.ExplicitFullName.ShouldBeNull();
intent.Alarm.ShouldBeNull();
intent.IsHistorized.ShouldBeFalse();
intent.IsArray.ShouldBeFalse();
}
// ---- Alarm (ported from ExtractTagAlarmTests) ----
[Theory]
[InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
[InlineData("not json", false, null, 0)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)]
[InlineData("{\"alarm\":{\"severity\":\"high\"}}", true, "AlarmCondition", 500)] // non-number severity → 500
[InlineData("{\"alarm\":{\"alarmType\":123}}", true, "AlarmCondition", 500)] // non-string type → default
public void Alarm_parses_or_null(string cfg, bool present, string? type, int sev)
{
var alarm = TagConfigIntent.Parse(cfg).Alarm;
if (!present) { alarm.ShouldBeNull(); return; }
alarm!.AlarmType.ShouldBe(type);
alarm.Severity.ShouldBe(sev);
}
[Theory]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)]
public void Alarm_historizeToAveva(string cfg, bool? expected)
{
TagConfigIntent.Parse(cfg).Alarm!.HistorizeToAveva.ShouldBe(expected);
}
// ---- Historize ----
[Theory]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true}", true, null)]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", true, "P.L.X")]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", false, null)]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)] // whitespace → null
[InlineData("{\"FullName\":\"A\",\"isHistorized\":\"yes\"}", false, null)] // non-bool → false
[InlineData("{\"FullName\":\"A\"}", false, null)]
public void Historize(string cfg, bool expectedHistorized, string? expectedTagname)
{
var intent = TagConfigIntent.Parse(cfg);
intent.IsHistorized.ShouldBe(expectedHistorized);
intent.HistorianTagname.ShouldBe(expectedTagname);
}
// ---- Array (ported from ExtractTagArrayTests) ----
[Theory]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)]
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)]
[InlineData(null, false, null)]
[InlineData("", false, null)]
[InlineData("not json {", false, null)]
[InlineData("[1,2]", false, null)]
[InlineData("{\"isArray\":\"yes\"}", false, null)]
[InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)]
[InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)]
[InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)]
[InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)]
public void Array_parses_or_defaults(string? cfg, bool expectedIsArray, uint? expectedLength)
{
var intent = TagConfigIntent.Parse(cfg);
intent.IsArray.ShouldBe(expectedIsArray);
intent.ArrayLength.ShouldBe(expectedLength);
}
// ---- never-throws property over an adversarial set ----
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json {")]
[InlineData("[1,2,3]")]
[InlineData("42")]
[InlineData("\"just a string\"")]
[InlineData("{\"alarm\":42,\"isArray\":\"x\",\"arrayLength\":true,\"isHistorized\":123}")]
public void Parse_never_throws(string? cfg)
{
Should.NotThrow(() => TagConfigIntent.Parse(cfg));
}
}