feat(opcua): EquipmentTagPlan IsArray/ArrayLength + composer ExtractTagArray + applier wire-in
This commit is contained in:
@@ -211,7 +211,7 @@ public sealed class Phase7Applier
|
||||
string? historianTagname = tag.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
|
||||
: null;
|
||||
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable, historianTagname);
|
||||
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable, historianTagname, tag.IsArray, tag.ArrayLength);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,9 +309,9 @@ public sealed class Phase7Applier
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
|
||||
}
|
||||
|
||||
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null)
|
||||
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
{
|
||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname); }
|
||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); }
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
|
||||
/// <c>historianTagname</c> string (Phase C); a null <see cref="HistorianTagname"/> means the historian
|
||||
/// tagname defaults to <see cref="FullName"/> (resolved later, not here). Both are parsed identically
|
||||
/// on the artifact-decode side for byte-parity.
|
||||
/// <see cref="IsArray"/> / <see cref="ArrayLength"/> carry the optional array intent parsed from
|
||||
/// <c>Tag.TagConfig</c>'s <c>isArray</c> bool + <c>arrayLength</c> uint: when <see cref="IsArray"/>
|
||||
/// the variable materialises as a 1-D array (<c>ValueRank=OneDimension</c>,
|
||||
/// <c>ArrayDimensions=[ArrayLength]</c>) rather than a scalar. A null <see cref="ArrayLength"/> means
|
||||
/// length 0 (unbounded). Both are parsed identically on the artifact-decode side for byte-parity.
|
||||
/// </summary>
|
||||
public sealed record EquipmentTagPlan(
|
||||
string TagId,
|
||||
@@ -95,7 +100,9 @@ public sealed record EquipmentTagPlan(
|
||||
bool Writable,
|
||||
EquipmentTagAlarmInfo? Alarm,
|
||||
bool IsHistorized = false,
|
||||
string? HistorianTagname = null);
|
||||
string? HistorianTagname = null,
|
||||
bool IsArray = false,
|
||||
uint? ArrayLength = null);
|
||||
|
||||
/// <summary>Native-alarm intent parsed from an equipment tag's <c>TagConfig.alarm</c> object. Null ⇒
|
||||
/// the tag is a plain value variable. <see cref="AlarmType"/> is an OPC UA Part 9 subtype string
|
||||
@@ -352,6 +359,7 @@ public static class Phase7Composer
|
||||
.Select(t =>
|
||||
{
|
||||
var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig);
|
||||
var (isArray, arrayLength) = ExtractTagArray(t.TagConfig);
|
||||
return new EquipmentTagPlan(
|
||||
TagId: t.TagId,
|
||||
EquipmentId: t.EquipmentId!,
|
||||
@@ -363,7 +371,9 @@ public static class Phase7Composer
|
||||
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
||||
Alarm: ExtractTagAlarm(t.TagConfig),
|
||||
IsHistorized: isHistorized,
|
||||
HistorianTagname: historianTagname);
|
||||
HistorianTagname: historianTagname,
|
||||
IsArray: isArray,
|
||||
ArrayLength: arrayLength);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -537,4 +547,34 @@ public static class Phase7Composer
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional array intent from a tag's <c>TagConfig</c> JSON: the <c>isArray</c>
|
||||
/// bool (absent / not a bool / non-object root / blank / malformed ⇒ <c>false</c>) and the optional
|
||||
/// <c>arrayLength</c> uint. The length is honoured ONLY when <c>isArray</c> is true AND the prop is a
|
||||
/// JSON number that fits <c>uint</c> (else <c>null</c> ⇒ unbounded, resolved later). Mirrors
|
||||
/// <see cref="ExtractTagHistorize"/> exactly in structure + null/blank/non-object/malformed-JSON
|
||||
/// tolerance. Never throws. The artifact-decode side
|
||||
/// (<c>DeploymentArtifact.ExtractTagArray</c>) MUST parse identically (byte-parity).</summary>
|
||||
internal static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
||||
var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl)
|
||||
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
|
||||
&& aEl.GetBoolean();
|
||||
uint? arrayLength = null;
|
||||
if (isArray
|
||||
&& doc.RootElement.TryGetProperty("arrayLength", out var lEl)
|
||||
&& lEl.ValueKind == JsonValueKind.Number
|
||||
&& lEl.TryGetUInt32(out var len))
|
||||
{
|
||||
arrayLength = len;
|
||||
}
|
||||
return (isArray, arrayLength);
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user