feat(opcua): EquipmentTagPlan IsArray/ArrayLength + composer ExtractTagArray + applier wire-in

This commit is contained in:
Joseph Doherty
2026-06-16 21:27:43 -04:00
parent 3172b7bdee
commit 71cc417182
4 changed files with 236 additions and 5 deletions
@@ -0,0 +1,155 @@
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="Phase7Composer.ExtractTagArray"/> parses the optional array intent from a
/// tag's <c>TagConfig</c> JSON exactly as <see cref="Phase7Composer.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="Phase7Composer.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)]
public void ExtractTagArray_parses_or_returns_defaults(string? cfg, bool expectedIsArray, uint? expectedLength)
{
var (isArray, arrayLength) = Phase7Composer.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="Phase7Composer.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 = Phase7Composer.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 = Phase7Composer.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();
}
}
@@ -174,6 +174,42 @@ public sealed class Phase7PlannerTests
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>An equipment Tag with the same id but a toggled <c>IsArray</c> flag (and otherwise
/// identical fields) must route to ChangedEquipmentTags. This pins that <c>IsArray</c> is part of
/// <see cref="EquipmentTagPlan.Equals"/> (record value-equality) so an array-flag-only deploy is not
/// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag.</summary>
[Fact]
public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags()
{
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
},
};
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16),
},
};
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse();
plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue();
plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Regression guard for structural equality on <see cref="EquipmentVirtualTagPlan.DependencyRefs"/>:
/// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan
/// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with