Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
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:
+4
-31
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user