Merge #610: server-side array writes gated per driver capability (DriverArrayWriteSupport); ArrayWritable on the surgical whitelist + UNS Backing; shape/IndexRange refusal; ValueRank>1 out of scope
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe # Conflicts: # docs/drivers/S7.md
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #610 — server-side array writes, over the wire. The address space is built by the PRODUCTION
|
||||
/// composer + applier (not hand-made <c>EnsureVariable</c> calls), so these tests prove the whole chain:
|
||||
/// <c>DriverArrayWriteSupport</c> → <c>RawTagPlan.ArrayWritable</c> / <c>UnsReferenceVariable.BackingArrayWritable</c>
|
||||
/// → the node's AccessLevel → a real client write through the realm-qualified <c>WriteOperate</c> gate →
|
||||
/// the node write gateway (the stand-in for <c>DriverHostActor</c>'s routing).
|
||||
/// <list type="bullet">
|
||||
/// <item>An S7 Int16 array (S7 encodes non-Bool arrays as one block) is writable on BOTH its raw node and
|
||||
/// its UNS mirror, and a whole-array write round-trips.</item>
|
||||
/// <item>An AbCip DInt array (AbCip has a scalar-only write encoder) stays read-only even though it is
|
||||
/// authored <c>ReadWrite</c>: the write is <c>BadNotWritable</c> at the node and never reaches the
|
||||
/// gateway — the "never advertise a write the driver will refuse" rule.</item>
|
||||
/// <item>A device-refused array write is reverted on whichever NodeId it was written through.</item>
|
||||
/// <item>A wrong-length array and an IndexRange (partial) write are refused inline, before dispatch, and
|
||||
/// leave the node holding its prior array.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class ArrayWriteWireTests
|
||||
{
|
||||
private const string S7ArrayRaw = "Plant/S7a/plc1/Levels";
|
||||
private const string AbArrayRaw = "Plant/Ab1/plc2/Counts";
|
||||
private const string S7ArrayUns = "filling/line-1/station-1/Levels";
|
||||
|
||||
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
|
||||
{
|
||||
public List<(string NodeId, object? Value, AddressSpaceRealm Realm)> Writes { get; } = [];
|
||||
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
||||
{
|
||||
lock (Writes) Writes.Add((nodeId, value, realm));
|
||||
return Task.FromResult(new NodeWriteOutcome(succeed, succeed ? null : "device refused"));
|
||||
}
|
||||
|
||||
public int Count { get { lock (Writes) return Writes.Count; } }
|
||||
}
|
||||
|
||||
/// <summary>Composes + materialises the two array tags and the UNS mirror of the S7 one, through the
|
||||
/// production composer and applier, then publishes an initial value to every node.</summary>
|
||||
private static async Task<ServerEventRig> StartAsync(string name, CancellationToken ct)
|
||||
{
|
||||
var rig = await ServerEventRig.StartAsync(name, encrypted: false, ct);
|
||||
|
||||
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
|
||||
var s7 = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-s7", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||
Name = "S7a", DriverType = "S7", DriverConfig = "{}",
|
||||
};
|
||||
var ab = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||
Name = "Ab1", DriverType = "AbCip", DriverConfig = "{}",
|
||||
};
|
||||
var plc1 = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" };
|
||||
var plc2 = new Device { DeviceId = "dev-2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" };
|
||||
var levels = new Tag
|
||||
{
|
||||
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int16",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":4}",
|
||||
};
|
||||
var counts = new Tag
|
||||
{
|
||||
TagId = "t-counts", DeviceId = "dev-2", Name = "Counts", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}",
|
||||
};
|
||||
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", UnsLineId = "line-1", Name = "station-1", MachineCode = "ST1" };
|
||||
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
|
||||
|
||||
var composition = AddressSpaceComposer.Compose(
|
||||
[area], [line], [equip], [s7, ab], Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: [reference], tags: [levels, counts],
|
||||
rawFolders: [folder], devices: [plc1, plc2]);
|
||||
|
||||
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
applier.MaterialiseHierarchy(composition).ShouldBe(0);
|
||||
applier.MaterialiseRawSubtree(composition).ShouldBe(0);
|
||||
applier.MaterialiseUnsReferences(composition).ShouldBe(0);
|
||||
|
||||
var ts = DateTime.UtcNow;
|
||||
rig.Sink.WriteValue(S7ArrayRaw, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
rig.Sink.WriteValue(S7ArrayUns, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
|
||||
rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
return rig;
|
||||
}
|
||||
|
||||
private static NodeId Raw(ISession s, string id) =>
|
||||
new(id, (ushort)s.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
|
||||
private static async Task<StatusCode> WriteAsync(ISession session, NodeId node, object value, CancellationToken ct, string? indexRange = null)
|
||||
{
|
||||
var response = await session.WriteAsync(null, new WriteValueCollection
|
||||
{
|
||||
new WriteValue { NodeId = node, AttributeId = Attributes.Value, IndexRange = indexRange, Value = new DataValue(new Variant(value)) },
|
||||
}, ct);
|
||||
return response.Results.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static async Task<short[]> ReadShortsAsync(ISession session, NodeId node, CancellationToken ct) =>
|
||||
(short[])(await session.ReadValueAsync(node, ct)).Value;
|
||||
|
||||
private static async Task<byte> ReadAccessLevelAsync(ISession session, NodeId node, CancellationToken ct)
|
||||
{
|
||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueIdCollection
|
||||
{
|
||||
new ReadValueId { NodeId = node, AttributeId = Attributes.UserAccessLevel },
|
||||
}, ct);
|
||||
return (byte)response.Results.ShouldHaveSingleItem().Value;
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (await condition()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
(await condition()).ShouldBeTrue("condition not met within timeout");
|
||||
}
|
||||
|
||||
/// <summary>A writable-capable array round-trips a whole-array write through BOTH NodeIds.</summary>
|
||||
[Fact]
|
||||
public async Task A_writable_array_roundtrips_a_whole_array_write_through_both_nodeids()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-write", ct);
|
||||
var gateway = new RecordingWriteGateway(succeed: true);
|
||||
rig.Nm.NodeWriteGateway = gateway;
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var raw = Raw(session, S7ArrayRaw);
|
||||
var uns = ServerEventRig.UnsNodeId(session, S7ArrayUns);
|
||||
|
||||
(await ReadAccessLevelAsync(session, raw, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
|
||||
(await ReadAccessLevelAsync(session, uns, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
|
||||
|
||||
(await WriteAsync(session, raw, new short[] { 10, 20, 30, 40 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
(await ReadShortsAsync(session, raw, ct)).ShouldBe(new short[] { 10, 20, 30, 40 });
|
||||
|
||||
(await WriteAsync(session, uns, new short[] { -1, -2, -3, -4 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
(await ReadShortsAsync(session, uns, ct)).ShouldBe(new short[] { -1, -2, -3, -4 });
|
||||
|
||||
lock (gateway.Writes)
|
||||
{
|
||||
gateway.Writes.Count.ShouldBe(2);
|
||||
gateway.Writes[0].Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
gateway.Writes[0].Value.ShouldBe(new short[] { 10, 20, 30, 40 });
|
||||
gateway.Writes[1].Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
gateway.Writes[1].Value.ShouldBe(new short[] { -1, -2, -3, -4 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An array on a driver with no array encoder stays read-only though authored ReadWrite.</summary>
|
||||
[Fact]
|
||||
public async Task An_array_the_driver_cannot_write_is_read_only_and_a_write_is_BadNotWritable()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-ro", ct);
|
||||
var gateway = new RecordingWriteGateway(succeed: true);
|
||||
rig.Nm.NodeWriteGateway = gateway;
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var ab = Raw(session, AbArrayRaw);
|
||||
|
||||
(await ReadAccessLevelAsync(session, ab, ct) & AccessLevels.CurrentWrite).ShouldBe((byte)0);
|
||||
(await WriteAsync(session, ab, new[] { 50, 60, 70, 80 }, ct)).ShouldBe((StatusCode)StatusCodes.BadNotWritable);
|
||||
((int[])(await session.ReadValueAsync(ab, ct)).Value).ShouldBe(new[] { 5, 6, 7, 8 });
|
||||
gateway.Count.ShouldBe(0);
|
||||
|
||||
// Positive control: the same session's write to the S7 array is dispatched, so the refusal is the node's.
|
||||
(await WriteAsync(session, Raw(session, S7ArrayRaw), new short[] { 9, 9, 9, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
gateway.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A device-refused array write reverts the node it was written through, on either NodeId.</summary>
|
||||
[Fact]
|
||||
public async Task A_device_refused_array_write_is_reverted_through_either_nodeid()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-revert", ct);
|
||||
rig.Nm.NodeWriteGateway = new RecordingWriteGateway(succeed: false);
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
|
||||
foreach (var node in new[] { Raw(session, S7ArrayRaw), ServerEventRig.UnsNodeId(session, S7ArrayUns) })
|
||||
{
|
||||
(await WriteAsync(session, node, new short[] { 99, 99, 99, 99 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
await WaitForAsync(async () => (await ReadShortsAsync(session, node, ct)).SequenceEqual(new short[] { 1, 2, 3, 4 }));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A wrong-length array and a partial (IndexRange) write are refused before dispatch.</summary>
|
||||
[Fact]
|
||||
public async Task A_wrong_length_or_index_range_array_write_is_refused_inline_and_leaves_the_node_intact()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-shape", ct);
|
||||
var gateway = new RecordingWriteGateway(succeed: true);
|
||||
rig.Nm.NodeWriteGateway = gateway;
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var raw = Raw(session, S7ArrayRaw);
|
||||
|
||||
StatusCode.IsBad(await WriteAsync(session, raw, new short[] { 7, 7, 7 }, ct)).ShouldBeTrue("a 3-element write to a 4-element array");
|
||||
(await WriteAsync(session, raw, new short[] { 7, 7, 7, 7 }, ct, indexRange: "0:3")).ShouldBe((StatusCode)StatusCodes.BadWriteNotSupported, "an IndexRange write, even one spanning the whole array");
|
||||
(await ReadShortsAsync(session, raw, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
|
||||
gateway.Count.ShouldBe(0);
|
||||
|
||||
// Positive control: a correctly-shaped whole-array write on the same node is dispatched.
|
||||
(await WriteAsync(session, raw, new short[] { 7, 7, 7, 7 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
gateway.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,41 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
v.Writable.ShouldBeFalse(); // Writable && !IsArray ⇒ an array mirror is read-only
|
||||
}
|
||||
|
||||
/// <summary>Gitea #610: an authored-ReadWrite ARRAY materialises writable on the raw node AND its UNS mirror
|
||||
/// exactly when the driver can write arrays (<c>ArrayWritable</c> / <c>BackingArrayWritable</c>), and
|
||||
/// read-only otherwise; an authored-Read array stays read-only either way.</summary>
|
||||
[Theory]
|
||||
[InlineData(true, true, true)]
|
||||
[InlineData(true, false, false)]
|
||||
[InlineData(false, true, false)]
|
||||
public void Array_nodes_are_writable_only_when_the_driver_can_write_arrays(bool authoredWritable, bool arrayWritable, bool expected)
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawTags = new[]
|
||||
{
|
||||
new RawTagPlan("t1", "Plant/A/dev/levels", "Plant/A/dev", "drv", "levels", "Int16",
|
||||
Writable: authoredWritable, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
||||
IsArray: true, ArrayLength: 4, ArrayWritable: arrayWritable),
|
||||
},
|
||||
UnsReferenceVariables = new[]
|
||||
{
|
||||
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/levels", "levels",
|
||||
"Plant/A/dev/levels", "Int16", Writable: authoredWritable,
|
||||
BackingIsArray: true, BackingArrayLength: 4, BackingArrayWritable: arrayWritable),
|
||||
},
|
||||
};
|
||||
|
||||
var applier = NewApplier(sink);
|
||||
applier.MaterialiseRawSubtree(composition);
|
||||
applier.MaterialiseUnsReferences(composition);
|
||||
|
||||
sink.Variables.Count.ShouldBe(2);
|
||||
sink.Variables.ShouldAllBe(v => v.Writable == expected && v.IsArray);
|
||||
}
|
||||
|
||||
/// <summary>The historian-resolution semantics carried over verbatim from the old back-lookup: a BLANK
|
||||
/// <c>BackingHistorianTagname</c> on a historized reference falls back to the <c>BackingRawPath</c> (which
|
||||
/// IS the backing raw tag's NodeId, the raw side's own default), and a non-historized reference registers
|
||||
|
||||
@@ -1727,6 +1727,35 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.RemoveCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Gitea #610: the array-write capability flipping on an already-array tag (its driver gained an
|
||||
/// array encoder, or the element type moved into the supported set) is applied IN PLACE on the raw node AND
|
||||
/// its UNS mirror — both become writable — with no rebuild. Without ArrayWritable on the classifier whitelist
|
||||
/// this edit would tear down every node; without BackingArrayWritable the mirror would stay read-only.</summary>
|
||||
[Fact]
|
||||
public void Array_write_capability_flip_updates_raw_and_uns_writability_in_place()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var rawBefore = RawTag("Plant/Modbus/dev1/block") with { Writable = true, IsArray = true, ArrayLength = 8 };
|
||||
var rawAfter = rawBefore with { ArrayWritable = true };
|
||||
var unsBefore = UnsRef("filling/line-1/station-1/block", "block") with { BackingIsArray = true, BackingArrayLength = 8 };
|
||||
var unsAfter = unsBefore with { BackingArrayWritable = true };
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(
|
||||
CompositionWith(rawBefore, unsBefore), CompositionWith(rawAfter, unsAfter));
|
||||
plan.ChangedRawTags.Count.ShouldBe(1);
|
||||
plan.ChangedUnsReferenceVariables.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.RemoveCalls.ShouldBeEmpty();
|
||||
sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Raw).Writable.ShouldBeTrue();
|
||||
sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Uns).Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>The one-way rebuild ratchet holds for the new raw-tag path: a surgical update that reports the
|
||||
/// node missing (false) falls back to a full rebuild.</summary>
|
||||
[Fact]
|
||||
|
||||
+5
-2
@@ -97,7 +97,7 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
|
||||
}
|
||||
|
||||
/// <summary>The in-place-updatable attribute whitelist (Writable / IsHistorized / HistorianTagname /
|
||||
/// DataType / IsArray / ArrayLength) on a non-alarm raw tag is surgical, not a rebuild.</summary>
|
||||
/// DataType / IsArray / ArrayLength / ArrayWritable) on a non-alarm raw tag is surgical, not a rebuild.</summary>
|
||||
[Theory]
|
||||
[InlineData("writable")]
|
||||
[InlineData("historized")]
|
||||
@@ -105,12 +105,14 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
|
||||
[InlineData("dataType")]
|
||||
[InlineData("array")]
|
||||
[InlineData("arrayLength")]
|
||||
[InlineData("arrayWritable")]
|
||||
public void Attribute_raw_tag_delta_is_not_a_rebuild(string field)
|
||||
{
|
||||
var baseline = RawTag("Plant/Modbus1/PLC-A/Speed");
|
||||
// ArrayLength moving ALONE (a resize of an already-array tag) — the whitelist must carry ArrayLength
|
||||
// in its own right, not only as a passenger of an IsArray flip. Its "previous" is already an array.
|
||||
var prev = field == "arrayLength" ? baseline with { IsArray = true, ArrayLength = 8 } : baseline;
|
||||
// ArrayWritable (#610) likewise moves on an already-array tag (a driver-type or element-type edit).
|
||||
var prev = field is "arrayLength" or "arrayWritable" ? baseline with { IsArray = true, ArrayLength = 8 } : baseline;
|
||||
var cur = field switch
|
||||
{
|
||||
"writable" => prev with { Writable = true },
|
||||
@@ -119,6 +121,7 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
|
||||
"dataType" => prev with { DataType = "Double" },
|
||||
"array" => prev with { IsArray = true, ArrayLength = 8 },
|
||||
"arrayLength" => prev with { ArrayLength = 16 },
|
||||
"arrayWritable" => prev with { ArrayWritable = true },
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field)),
|
||||
};
|
||||
|
||||
|
||||
+38
@@ -269,4 +269,42 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
a.RawTags.ShouldBe(b.RawTags);
|
||||
a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #610: <c>ArrayWritable</c> is derived per OWNING DRIVER at compose time, and the UNS mirror
|
||||
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; the identical shape on
|
||||
/// an AbCip driver (scalar-only write encoder) is not; a scalar is never flagged.</summary>
|
||||
[Fact]
|
||||
public void Array_write_capability_follows_the_owning_driver_and_reaches_the_uns_mirror()
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
||||
var s7 = new DriverInstance { DriverInstanceId = "drv-s7", ClusterId = "c1", RawFolderId = "f", Name = "S7a", DriverType = "S7", DriverConfig = "{}" };
|
||||
var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab1", DriverType = "AbCip", DriverConfig = "{}" };
|
||||
var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" };
|
||||
var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" };
|
||||
const string s7Array = "{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":4}";
|
||||
var tags = new[]
|
||||
{
|
||||
new Tag { TagId = "t1", DeviceId = "d1", Name = "Levels", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = s7Array },
|
||||
new Tag { TagId = "t2", DeviceId = "d1", Name = "Level", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW8\",\"dataType\":\"Int16\"}" },
|
||||
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}" },
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
||||
var refs = new[]
|
||||
{
|
||||
new UnsTagReference { UnsTagReferenceId = "r1", EquipmentId = "e", TagId = "t1" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r3", EquipmentId = "e", TagId = "t3" },
|
||||
};
|
||||
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip }, new[] { s7, ab }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2 });
|
||||
|
||||
c.RawTags.Single(t => t.Name == "Levels").ArrayWritable.ShouldBeTrue();
|
||||
c.RawTags.Single(t => t.Name == "Level").ArrayWritable.ShouldBeFalse(); // scalar: never flagged
|
||||
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,45 @@ public sealed class EquipmentWriteGateTests
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "any", node).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ───────────────────────────── #610 — array shape + IndexRange ─────────────────────────────
|
||||
|
||||
/// <summary>A whole array of the declared length proceeds; a wrong length or a scalar is refused inline.</summary>
|
||||
[Fact]
|
||||
public void Structure_array_node_takes_only_a_whole_array_of_its_declared_length()
|
||||
{
|
||||
var node = ArrayNode(DataTypeIds.Int16, length: 4);
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3, 4 }, node).ShouldBeNull();
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3 }, node)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure((short)1, node)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
}
|
||||
|
||||
/// <summary>An unbounded array (ArrayDimensions [0]) accepts any length.</summary>
|
||||
[Fact]
|
||||
public void Structure_unbounded_array_node_accepts_any_length()
|
||||
{
|
||||
var node = ArrayNode(DataTypeIds.Int16, length: 0);
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3 }, node).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A partial (IndexRange) write is refused; an absent range proceeds.</summary>
|
||||
[Fact]
|
||||
public void Range_only_whole_value_writes_proceed()
|
||||
{
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Empty).ShouldBeNull();
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Parse("1"))!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadWriteNotSupported);
|
||||
}
|
||||
|
||||
private static BaseDataVariableState ArrayNode(NodeId dataType, uint length)
|
||||
{
|
||||
var node = ValueNode(dataType);
|
||||
node.ValueRank = ValueRanks.OneDimension;
|
||||
node.ArrayDimensions = new ReadOnlyList<uint>(new UInt32Collection(new[] { length }));
|
||||
return node;
|
||||
}
|
||||
|
||||
private static BaseDataVariableState ValueNode(NodeId dataType) => new(null)
|
||||
{
|
||||
NodeId = new NodeId("eq/x", 2),
|
||||
|
||||
@@ -86,7 +86,7 @@ public sealed class UnsReferenceBackingParityTests
|
||||
{
|
||||
nameof(RawTagPlan.DataType), nameof(RawTagPlan.Writable), nameof(RawTagPlan.ReferencingEquipmentPaths),
|
||||
nameof(RawTagPlan.IsHistorized), nameof(RawTagPlan.HistorianTagname), nameof(RawTagPlan.IsArray),
|
||||
nameof(RawTagPlan.ArrayLength),
|
||||
nameof(RawTagPlan.ArrayLength), nameof(RawTagPlan.ArrayWritable),
|
||||
},
|
||||
ignoreOrder: true);
|
||||
|
||||
|
||||
+4
-2
@@ -49,7 +49,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
|
||||
var profile = new Tag
|
||||
{
|
||||
TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = "{\"isArray\":true,\"arrayLength\":16}",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"Float32\",\"isArray\":true,\"arrayLength\":16}",
|
||||
};
|
||||
var tempHi = new Tag
|
||||
{
|
||||
@@ -82,7 +82,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
|
||||
{
|
||||
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
|
||||
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
|
||||
new { TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", AccessLevel = 0, TagConfig = "{\"isArray\":true,\"arrayLength\":16}" },
|
||||
new { TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", AccessLevel = 0, TagConfig = "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"Float32\",\"isArray\":true,\"arrayLength\":16}" },
|
||||
new { TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "TempHi", DataType = "Boolean", AccessLevel = 1, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}" },
|
||||
},
|
||||
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
|
||||
@@ -106,6 +106,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
|
||||
var composedArray = composed.RawTags.Single(t => t.Name == "Profile");
|
||||
composedArray.IsArray.ShouldBeTrue();
|
||||
composedArray.ArrayLength.ShouldBe(16u);
|
||||
composedArray.ArrayWritable.ShouldBeTrue(); // #610: a Modbus holding-register Float32 array is writable
|
||||
var composedAlarm = composed.RawTags.Single(t => t.Name == "TempHi");
|
||||
composedAlarm.Alarm.ShouldNotBeNull();
|
||||
composedAlarm.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
|
||||
@@ -123,6 +124,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
|
||||
var composedProfileRef = composed.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-2");
|
||||
composedProfileRef.BackingIsArray.ShouldBeTrue();
|
||||
composedProfileRef.BackingArrayLength.ShouldBe(16u);
|
||||
composedProfileRef.BackingArrayWritable.ShouldBeTrue();
|
||||
composedProfileRef.BackingIsHistorized.ShouldBeFalse();
|
||||
|
||||
// Byte-parity: the three dual-namespace node sets are record-equal between the two seams.
|
||||
|
||||
Reference in New Issue
Block a user