Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 8m14s

Findings 01/C-1, 01/P-1 (single TagConfigIntent.Parse in Commons, 4 copies
delegate, parse-once) + 05 CONV-2/UNDER-1/UNDER-6 (shared strict TagConfigJson
readers, per-driver Inspect() + writable key, FOCAS capability pre-flight,
Modbus probe timeoutMs, deploy-gate Warn|Error). Phase C runtime-strict flip
deferred. T22/T24 deferred. Auto-merged AddressSpaceApplier.cs + DriverHostActor.cs
with R2-04/R2-10; verified OpcUaServer.Tests 286/286 + Runtime.Tests 396/396.
Build clean.
This commit is contained in:
Joseph Doherty
2026-07-13 11:29:32 -04:00
74 changed files with 2228 additions and 682 deletions
@@ -0,0 +1,46 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// Pins <see cref="DeviceConfigIntent"/> device-host extraction + normalization semantics (ported
/// from the historical composer statics; R2-11): null on blank/non-object/non-string/whitespace,
/// trim+lower normalization, and idempotence.
/// </summary>
public sealed class DeviceConfigIntentTests
{
[Theory]
[InlineData("{\"HostAddress\":\"10.0.0.5:8193\"}", "10.0.0.5:8193")]
[InlineData("{\"HostAddress\":\" HOST:8193 \"}", "host:8193")] // trim + lower
[InlineData("{\"HostAddress\":\"HOST:8193\",\"other\":1}", "host:8193")]
// null-yielding cases
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData("not json {", null)]
[InlineData("[1,2]", null)] // non-object root
[InlineData("{\"HostAddress\":123}", null)] // non-string
[InlineData("{\"HostAddress\":\" \"}", null)] // whitespace value
[InlineData("{\"other\":\"x\"}", null)] // absent
public void TryExtractHost(string? cfg, string? expected)
{
DeviceConfigIntent.TryExtractHost(cfg).ShouldBe(expected);
}
[Theory]
[InlineData(" HOST:8193 ", "host:8193")]
[InlineData("host:8193", "host:8193")]
public void NormalizeHost_trims_and_lowercases(string raw, string expected)
{
DeviceConfigIntent.NormalizeHost(raw).ShouldBe(expected);
}
[Fact]
public void NormalizeHost_is_idempotent()
{
var once = DeviceConfigIntent.NormalizeHost(" HOST:8193 ");
DeviceConfigIntent.NormalizeHost(once).ShouldBe(once);
}
}
@@ -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));
}
}
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Characterization net (R2-11) pinning the DraftValidator Galaxy rule's <em>explicit</em>-FullName
/// semantics across the golden TagConfig corpus BEFORE the private FullName reader is delegated to
/// <c>TagConfigIntent.Parse(...).ExplicitFullName</c>. Distinct from the walker/composer raw-blob
/// fallback: <c>GalaxyTagMissingReference</c> fires whenever the TagConfig has no usable
/// <em>explicit</em> string <c>FullName</c> — i.e. absent / non-string / whitespace / non-object /
/// malformed — and does NOT fire only when a non-blank string FullName is present. Must be green on
/// the unmodified tree and stay green through the swap.
/// </summary>
public sealed class DraftValidatorGalaxyFullNameCorpusTests
{
[Theory]
// Present, non-blank string FullName → rule does NOT fire
[InlineData("{\"FullName\":\"X.Y\"}", false)]
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", false)]
// Absent / non-string / whitespace / non-object / malformed → rule FIRES
[InlineData("{}", true)]
[InlineData("{\"region\":\"HoldingRegisters\"}", true)]
[InlineData("{\"FullName\":123}", true)]
[InlineData("{\"FullName\":\" \"}", true)]
[InlineData("[1,2]", true)]
[InlineData("not json {", true)]
public void Galaxy_missing_reference_fires_iff_no_explicit_string_FullName(string tagConfig, bool shouldFire)
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances =
[
new DriverInstance
{
DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1",
Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
},
],
Tags =
[
new Tag
{
TagId = "tag-galaxytag", DriverInstanceId = "d-galaxy", EquipmentId = "eq-1",
Name = "galaxytag", FolderPath = null, DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
},
],
};
var fired = DraftValidator.Validate(draft)
.Any(e => e.Code == "GalaxyTagMissingReference" && e.Context == "tag-galaxytag");
fired.ShouldBe(shouldFire);
}
}
@@ -0,0 +1,79 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Pins the shared strict-capable TagConfig field readers (R2-11, 05/CONV-2): the
/// <see cref="TagConfigJson.TryReadEnum{TEnum}"/> Absent/Valid/Invalid matrix (case-insensitive),
/// <see cref="TagConfigJson.ReadEnumOrDefault{TEnum}"/> lenient equivalence to the retired copies,
/// <see cref="TagConfigJson.ReadWritable"/> explicit-false-only semantics, and
/// <see cref="TagConfigJson.DescribeInvalidEnum{TEnum}"/> messages.
/// </summary>
public sealed class TagConfigJsonTests
{
/// <summary>Sample enum for the reader matrix (public so it can be an <c>[InlineData]</c> arg).</summary>
public enum Sample { Alpha, Beta, Gamma }
private static JsonElement Root(string json) => JsonDocument.Parse(json).RootElement;
[Theory]
[InlineData("{\"k\":\"Beta\"}", JsonEnumRead.Valid, Sample.Beta)]
[InlineData("{\"k\":\"beta\"}", JsonEnumRead.Valid, Sample.Beta)] // case-insensitive
[InlineData("{\"k\":\"GAMMA\"}", JsonEnumRead.Valid, Sample.Gamma)]
[InlineData("{\"k\":\"Deltaa\"}", JsonEnumRead.Invalid, default(Sample))] // typo → Invalid
[InlineData("{}", JsonEnumRead.Absent, default(Sample))] // absent
[InlineData("{\"k\":123}", JsonEnumRead.Absent, default(Sample))] // non-string → Absent (lenient)
[InlineData("{\"k\":null}", JsonEnumRead.Absent, default(Sample))]
public void TryReadEnum_matrix(string json, JsonEnumRead expected, Sample expectedValue)
{
var outcome = TagConfigJson.TryReadEnum<Sample>(Root(json), "k", out var v);
outcome.ShouldBe(expected);
v.ShouldBe(expectedValue);
}
[Theory]
[InlineData("{\"k\":\"Gamma\"}", Sample.Gamma)] // valid → parsed
[InlineData("{\"k\":\"typo\"}", Sample.Alpha)] // invalid → fallback
[InlineData("{}", Sample.Alpha)] // absent → fallback
[InlineData("{\"k\":42}", Sample.Alpha)] // non-string → fallback
public void ReadEnumOrDefault_is_lenient(string json, Sample expected)
{
TagConfigJson.ReadEnumOrDefault(Root(json), "k", Sample.Alpha).ShouldBe(expected);
}
[Theory]
[InlineData("{\"writable\":false}", true, false)] // explicit false → false
[InlineData("{\"writable\":true}", true, true)] // explicit true → default
[InlineData("{}", true, true)] // absent → default
[InlineData("{\"writable\":\"no\"}", true, true)] // non-bool → default
[InlineData("{}", false, false)] // absent, default false
[InlineData("{\"writable\":false}", false, false)] // explicit false, default false
public void ReadWritable_explicit_false_only(string json, bool dflt, bool expected)
{
TagConfigJson.ReadWritable(Root(json), dflt).ShouldBe(expected);
}
[Fact]
public void DescribeInvalidEnum_names_field_value_and_valid_members()
{
var msg = TagConfigJson.DescribeInvalidEnum<Sample>(Root("{\"dataType\":\"Betaa\"}"), "dataType");
msg.ShouldNotBeNull();
msg.ShouldContain("Betaa");
msg.ShouldContain("dataType");
msg.ShouldContain("Alpha");
msg.ShouldContain("Beta");
msg.ShouldContain("Gamma");
}
[Theory]
[InlineData("{\"dataType\":\"Beta\"}")] // valid → null
[InlineData("{}")] // absent → null
[InlineData("{\"dataType\":5}")] // non-string → null
public void DescribeInvalidEnum_null_when_absent_or_valid(string json)
{
TagConfigJson.DescribeInvalidEnum<Sample>(Root(json), "dataType").ShouldBeNull();
}
}
@@ -0,0 +1,36 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
/// <summary>
/// Characterization net (R2-11) pinning <see cref="EquipmentNodeWalker.ExtractFullName"/>'s
/// raw-blob-fallback semantics across the golden TagConfig corpus BEFORE the parser is delegated to
/// <c>TagConfigIntent.Parse</c>. The walker's FullName copy falls back to the raw blob whenever the
/// input is not a JSON object carrying a string <c>FullName</c> (blank / non-object / malformed /
/// absent / non-string). Whitespace + non-string-property values are returned verbatim (not trimmed).
/// Must be green on the unmodified tree and stay green through the swap.
/// </summary>
public sealed class EquipmentNodeWalkerFullNameCorpusTests
{
[Theory]
// object with a string FullName → the FullName string (verbatim, incl. whitespace)
[InlineData("{\"FullName\":\"X.Y\"}", "X.Y")]
[InlineData("{\"FullName\":\" \"}", " ")]
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5}", "A.B")]
// blank → raw blob (IsNullOrWhiteSpace short-circuit)
[InlineData(" ", " ")]
// non-object root → raw blob
[InlineData("[1,2]", "[1,2]")]
// malformed JSON → raw blob
[InlineData("not json {", "not json {")]
// object, FullName absent → raw blob
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":5}", "{\"region\":\"HoldingRegisters\",\"address\":5}")]
// object, FullName present but non-string → raw blob
[InlineData("{\"FullName\":123}", "{\"FullName\":123}")]
public void ExtractFullName_falls_back_to_raw_blob_except_for_string_FullName(string tagConfig, string expected)
{
EquipmentNodeWalker.ExtractFullName(tagConfig).ShouldBe(expected);
}
}
@@ -0,0 +1,50 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>R2-11 (05/CONV-2) strictness surface for the AbCip equipment-tag parser: lenient runtime freeze,
/// <c>Inspect</c> reports the typo, and the already-honoured <c>writable</c> key stays bit-identical through
/// the shared reader swap.</summary>
public sealed class AbCipEquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_DInt()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(AbCipDataType.DInt);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("DIntt");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured_bit_identical()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>R2-11 (05/CONV-2) strictness surface for the AbLegacy equipment-tag parser: lenient runtime
/// freeze, <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
public sealed class AbLegacyEquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_Int()
{
AbLegacyEquipmentTagParser.TryParse("{\"address\":\"N7:0\",\"dataType\":\"Intt\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(AbLegacyDataType.Int);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = AbLegacyEquipmentTagParser.Inspect("{\"address\":\"N7:0\",\"dataType\":\"Intt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
AbLegacyEquipmentTagParser.Inspect("{\"address\":\"N7:0\",\"dataType\":\"Int\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
AbLegacyEquipmentTagParser.TryParse("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
AbLegacyEquipmentTagParser.TryParse("{\"address\":\"N7:0\",\"dataType\":\"Int\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,61 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// R2-11 (05/UNDER-6): an equipment-tag reference whose address violates the device series'
/// capability matrix must fail to RESOLVE (surfacing <c>BadNodeIdUnknown</c>) — the same pre-flight
/// authored tags get at <c>InitializeAsync</c> — instead of reaching the wire and failing later with a
/// wire error. A capability-valid equipment tag still resolves + reads.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasEquipmentTagCapabilityGateTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver() => new(new FocasDriverOptions
{
// Series 16i: macro range 0..999 — MACRO:9500 is out of range.
Devices = [new FocasDeviceOptions(Host, Series: FocasCncSeries.Sixteen_i)],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
private static string EquipTag(string address) =>
$"{{\"address\":\"{address}\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"{Host}\"}}";
[Fact]
public async Task Capability_violating_equipment_tag_does_not_resolve()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([EquipTag("MACRO:9500")], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Capability_valid_equipment_tag_resolves_and_reads()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([EquipTag("MACRO:100")], CancellationToken.None);
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Equipment_tag_for_unknown_device_does_not_resolve()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var badDevice = "{\"address\":\"MACRO:100\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"focas://10.9.9.9:8193\"}";
var results = await drv.ReadAsync([badDevice], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
}
@@ -0,0 +1,57 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// R2-11 (05/CONV-2 + 05/UNDER-1) strictness surface for the FOCAS equipment-tag parser: lenient
/// runtime freeze on <c>dataType</c>, forced read-only (<c>Writable == false</c> regardless of any
/// authored <c>writable:true</c>), and <c>Inspect</c> reporting the typo + the write-request.
/// </summary>
public sealed class FocasEquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_Int32()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int322\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(FocasDataType.Int32);
}
[Fact]
public void Writable_is_always_false_even_when_requested_true()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_is_false_when_absent()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Inspect_warns_on_writable_true_request()
{
var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}");
warnings.ShouldContain(w => w.Contains("read-only") && w.Contains("writable"));
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int322\"}");
warnings.ShouldContain(w => w.Contains("Int322") && w.Contains("dataType"));
}
[Fact]
public void Inspect_clean_read_only_config_has_no_warnings()
{
FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\"}").ShouldBeEmpty();
}
}
@@ -0,0 +1,56 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// R2-11 (05/CONV-2): the Modbus probe parses the SAME factory DTO shape the driver factory parses, so
/// <c>timeoutMs</c> binds identically instead of being silently dropped (it never bound to the runtime
/// <c>ModbusDriverOptions.Timeout</c> TimeSpan). The OpcUaClient probe/factory parse-parity rule.
/// </summary>
public sealed class ModbusDriverProbeParityTests
{
[Fact]
public void Config_timeoutMs_binds_as_the_effective_probe_timeout()
{
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
"{\"host\":\"10.0.0.5\",\"port\":502,\"unitId\":3,\"timeoutMs\":250}",
fallbackTimeout: TimeSpan.FromSeconds(5));
error.ShouldBeNull();
target.ShouldNotBeNull();
target!.Value.Host.ShouldBe("10.0.0.5");
target.Value.Port.ShouldBe(502);
target.Value.UnitId.ShouldBe((byte)3);
target.Value.Timeout.ShouldBe(TimeSpan.FromMilliseconds(250));
}
[Fact]
public void Absent_timeoutMs_falls_back_to_the_caller_timeout()
{
var (target, _) = ModbusDriverProbe.TryParseProbeTarget(
"{\"host\":\"10.0.0.5\",\"port\":502}", fallbackTimeout: TimeSpan.FromSeconds(5));
target!.Value.Timeout.ShouldBe(TimeSpan.FromSeconds(5));
target.Value.UnitId.ShouldBe((byte)1); // DTO default
}
[Fact]
public void Missing_host_is_an_error()
{
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
"{\"port\":0}", fallbackTimeout: TimeSpan.FromSeconds(5));
target.ShouldBeNull();
error.ShouldNotBeNull();
}
[Fact]
public void Invalid_json_is_an_error()
{
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
"not valid json {{", fallbackTimeout: TimeSpan.FromSeconds(5));
target.ShouldBeNull();
error.ShouldContain("invalid");
}
}
@@ -0,0 +1,71 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// R2-11 (05/CONV-2) strictness surface for the Modbus equipment-tag parser: the runtime stays lenient
/// (freeze), the new <c>Inspect</c> reports the silently-defaulted typo, and the <c>writable</c> key is
/// now honoured (default true).
/// </summary>
public sealed class ModbusEquipmentTagParserStrictnessTests
{
// ---- Phase-A behaviour freeze: a typo'd enum still silently defaults ----
[Fact]
public void Freeze_typo_dataType_still_defaults_to_Int16()
{
ModbusEquipmentTagParser.TryParse(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(ModbusDataType.Int16);
}
// ---- Inspect reports the typo ----
[Fact]
public void Inspect_reports_typo_dataType_with_field_and_valid_values()
{
var warnings = ModbusEquipmentTagParser.Inspect(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt16");
warnings[0].ShouldContain("dataType");
warnings[0].ShouldContain("Int16");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
ModbusEquipmentTagParser.Inspect(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}")
.ShouldBeEmpty();
}
[Fact]
public void Inspect_malformed_json_warns_unresolvable()
{
ModbusEquipmentTagParser.Inspect("{not json").ShouldHaveSingleItem();
}
// ---- writable now honoured ----
[Fact]
public void Writable_false_is_honoured()
{
ModbusEquipmentTagParser.TryParse(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
ModbusEquipmentTagParser.TryParse(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>R2-11 (05/CONV-2) strictness surface for the S7 equipment-tag parser: lenient runtime freeze,
/// <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
public sealed class S7EquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_Int16()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(S7DataType.Int16);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt16");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>R2-11 (05/CONV-2) strictness surface for the TwinCAT equipment-tag parser: lenient runtime
/// freeze, <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
public sealed class TwinCATEquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_DInt()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(TwinCATDataType.DInt);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = TwinCATEquipmentTagParser.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("DIntt");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
TwinCATEquipmentTagParser.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// R2-11 (05/UNDER-6): the new optional <c>writable</c> key round-trips through the driver-typed tag
/// config models — a value the editors' new checkbox authors. Absent stays absent (so existing configs
/// are untouched); an explicit <c>false</c> round-trips; unknown keys are preserved.
/// </summary>
public sealed class TagConfigModelWritableTests
{
[Fact]
public void Modbus_absent_writable_stays_omitted()
{
var m = ModbusTagConfigModel.FromJson("{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}");
m.Writable.ShouldBeNull();
m.ToJson().ShouldNotContain("writable");
}
[Fact]
public void Modbus_explicit_false_round_trips()
{
var m = ModbusTagConfigModel.FromJson("{\"address\":1,\"writable\":false}");
m.Writable.ShouldBe(false);
var json = ModbusTagConfigModel.FromJson(m.ToJson());
json.Writable.ShouldBe(false);
}
[Fact]
public void Modbus_setting_writable_true_writes_the_key()
{
var m = ModbusTagConfigModel.FromJson("{\"address\":1}");
m.Writable = true;
ModbusTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(true);
}
[Fact]
public void Writable_preserves_unknown_keys()
{
var m = ModbusTagConfigModel.FromJson("{\"address\":1,\"writable\":false,\"foo\":\"bar\"}");
m.ToJson().ShouldContain("foo");
m.ToJson().ShouldContain("bar");
}
[Fact]
public void S7_writable_round_trips()
{
var m = S7TagConfigModel.FromJson("{\"address\":\"DB1.DBW0\",\"writable\":false}");
m.Writable.ShouldBe(false);
S7TagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
}
[Fact]
public void AbCip_writable_round_trips()
{
var m = AbCipTagConfigModel.FromJson("{\"tagPath\":\"Motor.Speed\",\"writable\":false}");
m.Writable.ShouldBe(false);
AbCipTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
}
[Fact]
public void AbLegacy_writable_round_trips()
{
var m = AbLegacyTagConfigModel.FromJson("{\"address\":\"N7:0\",\"writable\":false}");
m.Writable.ShouldBe(false);
AbLegacyTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
}
[Fact]
public void TwinCAT_writable_round_trips()
{
var m = TwinCATTagConfigModel.FromJson("{\"symbolPath\":\"MAIN.x\",\"writable\":false}");
m.Writable.ShouldBe(false);
TwinCATTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
}
[Fact]
public void Absent_writable_omitted_across_models()
{
ModbusTagConfigModel.FromJson("{\"address\":1}").ToJson().ShouldNotContain("writable");
S7TagConfigModel.FromJson("{\"address\":\"DB1.DBW0\"}").ToJson().ShouldNotContain("writable");
AbCipTagConfigModel.FromJson("{\"tagPath\":\"M.S\"}").ToJson().ShouldNotContain("writable");
AbLegacyTagConfigModel.FromJson("{\"address\":\"N7:0\"}").ToJson().ShouldNotContain("writable");
TwinCATTagConfigModel.FromJson("{\"symbolPath\":\"MAIN.x\"}").ToJson().ShouldNotContain("writable");
}
}
@@ -0,0 +1,131 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R2-11 (05/CONV-2) — the deploy gate's TagConfig strictness inspection, exercised THROUGH the actor
/// (the F10b/PR#423 lesson: an inspector built but never called is inert). Warn mode (default) accepts
/// the deploy with the warning surfaced in the result Message; Error mode rejects the same draft; a
/// clean config and an unmapped (Galaxy) tag produce no TagConfig noise.
/// </summary>
public sealed class AdminOperationsActorTagConfigGateTests : ControlPlaneActorTestBase
{
private static void Seed(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, string driverType, string tagConfig,
string tagName = "flow")
{
var uuid = Guid.NewGuid();
var equipmentId = DraftValidator.DeriveEquipmentId(uuid);
using var db = dbFactory.CreateDbContext();
db.Namespaces.Add(new Namespace
{
NamespaceId = "ns-1", ClusterId = "", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "d-1", ClusterId = "", NamespaceId = "ns-1",
Name = "drv", DriverType = driverType, DriverConfig = "{}",
});
db.Equipment.Add(new Equipment
{
EquipmentUuid = uuid, EquipmentId = equipmentId, Name = "pump-01",
DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "PUMP01",
});
db.Tags.Add(new Tag
{
TagId = "tag-1", DriverInstanceId = "d-1", EquipmentId = equipmentId,
Name = tagName, DataType = "Int16", AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
});
db.SaveChanges();
}
private const string TypoModbus = "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}";
private const string CleanModbus = "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}";
[Fact]
public void Warn_mode_accepts_and_surfaces_the_warning_in_the_message()
{
var dbFactory = NewInMemoryDbFactory();
Seed(dbFactory, "Modbus", TypoModbus);
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Warn));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
reply.Message.ShouldNotBeNull();
reply.Message.ShouldContain("Intt16");
reply.Message.ShouldContain("tag-1");
}
[Fact]
public void Error_mode_rejects_the_same_draft()
{
var dbFactory = NewInMemoryDbFactory();
Seed(dbFactory, "Modbus", TypoModbus);
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Error));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
reply.Message.ShouldNotBeNull();
reply.Message.ShouldContain("TagConfigInvalid");
reply.Message.ShouldContain("Intt16");
using var verify = dbFactory.CreateDbContext();
verify.Deployments.Count().ShouldBe(0);
}
[Fact]
public void Clean_config_accepts_with_no_tagconfig_noise()
{
var dbFactory = NewInMemoryDbFactory();
Seed(dbFactory, "Modbus", CleanModbus);
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Warn));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
(reply.Message is null || !reply.Message.Contains("Intt16")).ShouldBeTrue();
}
[Fact]
public void Unmapped_galaxy_tag_is_skipped_even_in_error_mode()
{
var dbFactory = NewInMemoryDbFactory();
// Galaxy tag carrying a FullName (passes the DraftValidator Galaxy rule); the inspector has no
// Galaxy mapping, so it is skipped regardless of mode.
Seed(dbFactory, "GalaxyMxGateway", "{\"FullName\":\"Obj.Attr\",\"dataType\":\"nonsense\"}");
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Error));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3)).Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
}
}
@@ -0,0 +1,62 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>R2-11 (05/CONV-2): the DriverType-dispatched deploy-time TagConfig inspector — known drivers
/// route to their parser's Inspect; unmapped drivers (Galaxy/OpcUaClient) skip; keys are case-insensitive.</summary>
public sealed class EquipmentTagConfigInspectorTests
{
[Fact]
public void Modbus_typo_dispatches_to_one_warning()
{
var warnings = EquipmentTagConfigInspector.Inspect(
"Modbus", "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt16");
}
[Fact]
public void Clean_modbus_config_has_no_warnings()
{
EquipmentTagConfigInspector.Inspect(
"Modbus", "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}")
.ShouldBeEmpty();
}
[Theory]
[InlineData("GalaxyMxGateway")]
[InlineData("OpcUaClient")]
[InlineData("Unknown")]
public void Unmapped_driver_types_are_skipped(string driverType)
{
EquipmentTagConfigInspector.Inspect(driverType, "{\"dataType\":\"whatever\"}").ShouldBeEmpty();
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeFalse();
}
[Theory]
[InlineData("modbus")]
[InlineData("FOCAS")]
[InlineData("focas")]
[InlineData("twincat")]
public void Driver_type_matching_is_case_insensitive(string driverType)
{
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue();
}
[Fact]
public void Focas_writable_true_dispatches_read_only_warning()
{
var warnings = EquipmentTagConfigInspector.Inspect(
"Focas", "{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}");
warnings.ShouldContain(w => w.Contains("read-only"));
}
[Fact]
public void Null_inputs_are_safe()
{
EquipmentTagConfigInspector.Inspect(null, "{}").ShouldBeEmpty();
EquipmentTagConfigInspector.Inspect("Modbus", null).ShouldBeEmpty();
}
}
@@ -8,8 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// Covers follow-up E projection: <see cref="EquipmentNode"/> carries the equipment's
/// <c>DriverInstanceId</c> / <c>DeviceId</c> bindings and the resolved <c>DeviceHost</c> (parsed from
/// the bound <see cref="Device"/>'s schemaless <c>DeviceConfig</c> JSON via the shared
/// <see cref="AddressSpaceComposer.TryExtractDeviceHost"/>). A later task grafts a driver's discovered
/// <c>DeviceConfigIntent.TryExtractHost</c>). A later task grafts a driver's discovered
/// FixedTree onto a zero-tag equipment and partitions a multi-device driver by host using these.
/// <para>The direct host-extraction + normalization unit tests moved to
/// <c>Commons.Tests/DeviceConfigIntentTests</c> (R2-11) — this suite keeps the Compose-level projection
/// coverage.</para>
/// </summary>
public sealed class AddressSpaceComposerDeviceHostTests
{
@@ -75,36 +78,6 @@ public sealed class AddressSpaceComposerDeviceHostTests
noHost.DeviceHost.ShouldBeNull();
}
/// <summary>The shared host extractor normalizes (trim + lower-case) and tolerates every malformed
/// shape (blank / non-object / no string HostAddress / blank value / non-JSON) by returning null.</summary>
[Theory]
[InlineData("{\"HostAddress\":\"10.201.31.5:8193\"}", "10.201.31.5:8193")]
[InlineData("{\"HostAddress\":\" HOST-A:8193 \"}", "host-a:8193")] // trimmed + lower-cased
[InlineData("{\"HostAddress\":\"\"}", null)] // blank value
[InlineData("{\"HostAddress\":1234}", null)] // non-string
[InlineData("{\"Port\":502}", null)] // absent
[InlineData("[]", null)] // non-object root
[InlineData("not json", null)] // malformed
[InlineData("", null)] // blank
public void TryExtractDeviceHost_normalizes_and_tolerates(string? deviceConfig, string? expected)
{
AddressSpaceComposer.TryExtractDeviceHost(deviceConfig).ShouldBe(expected);
}
/// <summary>The extracted-out shared normalizer (the single source of truth the FixedTree-partition path
/// reuses on a driver-discovered device-host folder segment) trims + lower-cases, and is idempotent on an
/// already-normalized value — so a segment like <c>" HOST-A:8193 "</c> matches a stored
/// <c>"host-a:8193"</c> DeviceHost.</summary>
[Theory]
[InlineData("10.201.31.5:8193", "10.201.31.5:8193")]
[InlineData(" HOST-A:8193 ", "host-a:8193")]
[InlineData("host-a:8193", "host-a:8193")] // idempotent
[InlineData("H1", "h1")]
public void NormalizeDeviceHost_trims_and_lowercases(string raw, string expected)
{
AddressSpaceComposer.NormalizeDeviceHost(raw).ShouldBe(expected);
}
private static Equipment NewEquipment(string id, string? driver, string? device) => new()
{
EquipmentId = id,
@@ -1,37 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public class 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)]
public void ExtractTagAlarm_parses_or_returns_null(string cfg, bool present, string? type, int sev)
{
var info = AddressSpaceComposer.ExtractTagAlarm(cfg);
if (!present) { info.ShouldBeNull(); return; }
info!.AlarmType.ShouldBe(type);
info.Severity.ShouldBe(sev);
}
/// <summary>historizeToAveva (bool?, absent ⇒ null ⇒ historize): an explicit true/false parses
/// through; a missing or non-bool node yields null (the HistorianAdapterActor gate then treats it as
/// default-on). Mirrors the scripted-alarm opt-out posture.</summary>
[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 ExtractTagAlarm_parses_historizeToAveva(string cfg, bool? expected)
{
var info = AddressSpaceComposer.ExtractTagAlarm(cfg);
info.ShouldNotBeNull();
info!.HistorizeToAveva.ShouldBe(expected);
}
}
@@ -1,159 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Verifies <see cref="AddressSpaceComposer.ExtractTagArray"/> parses the optional array intent from a
/// tag's <c>TagConfig</c> JSON exactly as <see cref="AddressSpaceComposer.ExtractTagHistorize"/> parses
/// the historize intent: the <c>isArray</c> bool (absent / not a bool / non-object root / blank /
/// malformed ⇒ <c>false</c>) and the optional <c>arrayLength</c> uint (only honoured when
/// <c>isArray</c> is true AND the prop is a JSON number that fits uint; else <c>null</c>). Never
/// throws. Also pins the end-to-end <see cref="AddressSpaceComposer.Compose"/> thread-through onto
/// <see cref="EquipmentTagPlan.IsArray"/> / <see cref="EquipmentTagPlan.ArrayLength"/>.
/// </summary>
public class ExtractTagArrayTests
{
[Theory]
// isArray true with an explicit arrayLength.
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)]
// isArray true, no arrayLength ⇒ length null.
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)]
// Absent isArray ⇒ false (arrayLength ignored even if present).
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
// arrayLength present but isArray false ⇒ length null (only honoured when the flag is true).
[InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)]
// arrayLength absent-with-flag honoured-as-null when isArray true but no length.
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)]
// null / empty / malformed-JSON / array-root ⇒ (false, null), never throws.
[InlineData(null, false, null)]
[InlineData("", false, null)]
[InlineData("not json {", false, null)]
[InlineData("[1,2]", false, null)]
// Wrong type for isArray (string, not bool) ⇒ false.
[InlineData("{\"isArray\":\"yes\"}", false, null)]
// Wrong type for arrayLength (string, not number) ⇒ length null, flag still honoured.
[InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)]
// Negative arrayLength does not fit uint ⇒ length null, flag still honoured.
[InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)]
// Float arrayLength (16.5) is not an exact uint ⇒ TryGetUInt32 rejects it ⇒ length null.
[InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)]
// Overflow arrayLength (uint.MaxValue + 1 = 4294967296) does not fit uint ⇒ length null.
[InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)]
public void ExtractTagArray_parses_or_returns_defaults(string? cfg, bool expectedIsArray, uint? expectedLength)
{
var (isArray, arrayLength) = AddressSpaceComposer.ExtractTagArray(cfg);
isArray.ShouldBe(expectedIsArray);
arrayLength.ShouldBe(expectedLength);
}
/// <summary>End-to-end: an equipment tag whose TagConfig carries <c>isArray</c>/<c>arrayLength</c>
/// surfaces those on its <see cref="EquipmentTagPlan"/> through <see cref="AddressSpaceComposer.Compose"/>,
/// exactly as the historize keys thread through.</summary>
[Fact]
public void Compose_threads_array_keys_onto_equipment_tag_plan()
{
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
var driver = new DriverInstance
{
DriverInstanceId = "drv-1",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Modbus1",
DriverType = "Modbus",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment
{
EquipmentId = "eq-1",
DriverInstanceId = "drv-1",
UnsLineId = "line-1",
Name = "Machine_001",
MachineCode = "MACHINE_001",
};
var arrayTag = new Tag
{
TagId = "tag-arr",
DriverInstanceId = "drv-1",
EquipmentId = "eq-1",
FolderPath = null,
Name = "Buffer",
DataType = "Int16",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"40001\",\"isArray\":true,\"arrayLength\":16}",
};
var result = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
new[] { arrayTag }, new[] { ns });
var tag = result.EquipmentTags.ShouldHaveSingleItem();
tag.TagId.ShouldBe("tag-arr");
tag.IsArray.ShouldBeTrue();
tag.ArrayLength.ShouldBe((uint)16);
}
/// <summary>End-to-end: a scalar equipment tag (no array keys) yields IsArray=false, ArrayLength=null.</summary>
[Fact]
public void Compose_leaves_scalar_equipment_tag_plan_unflagged()
{
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
var driver = new DriverInstance
{
DriverInstanceId = "drv-1",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Modbus1",
DriverType = "Modbus",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment
{
EquipmentId = "eq-1",
DriverInstanceId = "drv-1",
UnsLineId = "line-1",
Name = "Machine_001",
MachineCode = "MACHINE_001",
};
var scalarTag = new Tag
{
TagId = "tag-scalar",
DriverInstanceId = "drv-1",
EquipmentId = "eq-1",
FolderPath = null,
Name = "Speed",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"40005\"}",
};
var result = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
new[] { scalarTag }, new[] { ns });
var tag = result.EquipmentTags.ShouldHaveSingleItem();
tag.IsArray.ShouldBeFalse();
tag.ArrayLength.ShouldBeNull();
}
}
@@ -1,35 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public class ExtractTagHistorizeTests
{
[Theory]
// isHistorized true, no explicit tagname ⇒ tagname null (defaults to FullName later).
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true}", true, null)]
// isHistorized true with an explicit historian-tagname override.
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\"WW.Tag\"}", true, "WW.Tag")]
// Absent isHistorized ⇒ false.
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
// historianTagname parses independently of the flag.
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":false,\"historianTagname\":\"WW.Tag\"}", false, "WW.Tag")]
// Blank/whitespace tagname ⇒ null.
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)]
// null / empty / malformed-JSON / array-root ⇒ (false, null), never throws.
[InlineData(null, false, null)]
[InlineData("", false, null)]
[InlineData("not json {", false, null)]
[InlineData("[1,2]", false, null)]
// Wrong type for isHistorized (string, not bool) ⇒ false.
[InlineData("{\"isHistorized\":\"yes\"}", false, null)]
// Wrong type for historianTagname (number, not string) ⇒ tagname null, flag still honoured.
[InlineData("{\"isHistorized\":true,\"historianTagname\":123}", true, null)]
public void ExtractTagHistorize_parses_or_returns_defaults(string? cfg, bool expectedHistorized, string? expectedTagname)
{
var (isHistorized, historianTagname) = AddressSpaceComposer.ExtractTagHistorize(cfg);
isHistorized.ShouldBe(expectedHistorized);
historianTagname.ShouldBe(expectedTagname);
}
}
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// and the artifact decoder (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>).
/// A secondary/follower node decoding a serialized artifact MUST see the same DeviceHost as the
/// primary so it grafts FixedTree / partitions multi-device drivers identically. Both sides resolve
/// the host through the shared <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> (single source
/// the host through the shared <c>DeviceConfigIntent.TryExtractHost</c> (single source
/// of truth + identical trim + lower-case normalization).
/// </summary>
public sealed class DeploymentArtifactDeviceHostParityTests
@@ -197,7 +197,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// DeviceHost matches. Asserts (a) TWO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> (one per
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
/// vs stored "h1" wrinkle proves the SHARED <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> match.</summary>
/// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
[Fact]
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
{
@@ -0,0 +1,111 @@
using System.Linq;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The cross-seam characterization net for R2-11 (01/C-1 + 01/P-1). Composes one equipment tag per
/// <see cref="TagConfigGoldenCorpus"/> blob, serialises the SAME draft to the artifact blob shape,
/// decodes it, and asserts the decoded <see cref="EquipmentTagPlan"/> list equals the composer's
/// element-wise (positional-record value equality) over the WHOLE corpus. Because both producers parse
/// the identical raw <c>TagConfig</c> string, this proves the four byte-parity parse sites agree TODAY,
/// and is the permanent regression guard held green through every subsequent seam swap
/// (<c>TagConfigIntent.Parse</c> consolidation).
/// </summary>
public sealed class TagConfigCorpusParityTests
{
[Fact]
public void Composer_and_artifact_agree_over_the_whole_TagConfig_corpus()
{
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
var driver = new DriverInstance
{
DriverInstanceId = "drv-1",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Modbus1",
DriverType = "Modbus",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment
{
EquipmentId = "eq-1",
DriverInstanceId = "drv-1",
UnsLineId = "line-1",
Name = "Machine_001",
MachineCode = "MACHINE_001",
};
var tags = TagConfigGoldenCorpus.Blobs
.Select((blob, i) => new Tag
{
TagId = $"tag-{i:D2}",
DriverInstanceId = "drv-1",
EquipmentId = "eq-1",
FolderPath = null,
Name = $"Sig{i:D2}",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = blob,
})
.ToArray();
// ---- Side 1: the live-edit composer ----
var composed = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(), tags, new[] { ns });
// ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ----
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind },
},
DriverInstances = new[]
{
new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId },
},
Tags = tags.Select(ToSnapshot).ToArray(),
});
var decoded = DeploymentArtifact.ParseComposition(blob);
decoded.EquipmentTags.Count.ShouldBe(tags.Length);
// Full byte-parity: every field, same order (positional-record value equality). A field-by-field
// spell-out per blob so a divergence names the offending corpus entry.
for (var i = 0; i < composed.EquipmentTags.Count; i++)
{
var c = composed.EquipmentTags[i];
var d = decoded.EquipmentTags.Single(t => t.TagId == c.TagId);
d.ShouldBe(c, customMessage: $"corpus blob #{c.TagId} diverged: '{tags.Single(t => t.TagId == c.TagId).TagConfig}'");
}
decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue();
}
private static object ToSnapshot(Tag t) => new
{
t.TagId,
t.DriverInstanceId,
t.EquipmentId,
t.Name,
t.FolderPath,
t.DataType,
AccessLevel = (int)t.AccessLevel,
t.TagConfig,
};
}
@@ -0,0 +1,83 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The shared golden corpus of <c>TagConfig</c> JSON blobs used to characterise the cross-driver
/// platform-intent parse (FullName / alarm / isHistorized / historianTagname / isArray / arrayLength).
/// Every entry is a NON-NULL string (the DB <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a
/// non-null TagConfig for a real deploy) so the compose→encode→decode parity test
/// (<see cref="TagConfigCorpusParityTests"/>) can round-trip each through both equipment-tag producers
/// and assert byte-parity field-by-field. The <c>null</c>-input divergence (composer returns the raw
/// null, artifact coalesces to "") is a documented seam handled only at the
/// <c>TagConfigIntent.Parse</c> unit level, not through the parity round-trip.
/// <para>Covers: happy path, blank, non-object root, malformed JSON, FullName absent/non-string/whitespace,
/// alarm object with each field absent/wrong-type, historizeToAveva true/false/non-bool,
/// historianTagname whitespace, isArray/arrayLength combinations (incl. negative/overflow/non-number/
/// length-without-flag/zero), unknown keys, and mixed platform+driver keys.</para>
/// </summary>
public static class TagConfigGoldenCorpus
{
/// <summary>The corpus blobs, index-ordered. The parity test builds one equipment tag per blob.</summary>
public static readonly IReadOnlyList<string> Blobs = new[]
{
// 0 happy path
"{\"FullName\":\"X.Y\"}",
// 1 blank (whitespace) — FullName falls back to the raw (blank) blob on both seams
" ",
// 2 non-object root
"[1,2]",
// 3 malformed JSON
"not json {",
// 4 FullName absent (driver keys only) — FullName = raw blob
"{\"region\":\"HoldingRegisters\",\"address\":5}",
// 5 FullName present but non-string — FullName = raw blob
"{\"FullName\":123}",
// 6 FullName whitespace value (not trimmed)
"{\"FullName\":\" \"}",
// 7 alarm empty object → defaults (AlarmCondition, 500, historize null)
"{\"FullName\":\"A\",\"alarm\":{}}",
// 8 alarm fully specified
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
// 9 alarm non-object → no alarm
"{\"FullName\":\"A\",\"alarm\":\"oops\"}",
// 10 alarm historizeToAveva true
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}",
// 11 alarm historizeToAveva false
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}",
// 12 alarm historizeToAveva non-bool → null
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}",
// 13 alarm severity non-number → 500
"{\"FullName\":\"A\",\"alarm\":{\"severity\":\"high\"}}",
// 14 alarm alarmType non-string → AlarmCondition
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":123}}",
// 15 isHistorized true
"{\"FullName\":\"A\",\"isHistorized\":true}",
// 16 isHistorized true + explicit tagname
"{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}",
// 17 isHistorized false + JSON-null tagname
"{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}",
// 18 historianTagname whitespace → null
"{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}",
// 19 isHistorized non-bool → false
"{\"FullName\":\"A\",\"isHistorized\":\"yes\"}",
// 20 isArray true + length
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16}",
// 21 isArray true, no length
"{\"FullName\":\"A\",\"isArray\":true}",
// 22 isArray false + length → scalar
"{\"FullName\":\"A\",\"isArray\":false,\"arrayLength\":16}",
// 23 isArray true, length 0 → true, 0
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":0}",
// 24 arrayLength non-number → true, null
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":\"16\"}",
// 25 arrayLength negative → true, null
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":-1}",
// 26 arrayLength float → true, null
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16.5}",
// 27 arrayLength overflow (uint.MaxValue + 1) → true, null
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":4294967296}",
// 28 unknown + mixed platform/driver keys
"{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5,\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}",
// 29 all intents combined
"{\"FullName\":\"C.D\",\"isArray\":true,\"arrayLength\":4,\"isHistorized\":true,\"historianTagname\":\"H\",\"alarm\":{\"alarmType\":\"DiscreteAlarm\",\"severity\":900,\"historizeToAveva\":false}}",
};
}