Merge master d520f77b into remed/lane-E5

Refs #748
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:46:25 -04:00
12 changed files with 864 additions and 46 deletions
@@ -18,9 +18,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <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>An AbCip BOOL array (bit-packed DWORDs: AbCip's array encoder refuses it, though it encodes the atomic
/// numeric arrays since #737) 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>
@@ -110,7 +111,8 @@ public sealed class ArrayWriteWireTests
{
TagId = "t-counts", DeviceId = "dev-2", Name = "Counts", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}",
// A BOOL array: the AbCip array encoder has no encoding for it (a DInt array is writable since #737).
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"Bool\",\"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" };
@@ -271,8 +271,8 @@ public sealed class AddressSpaceComposerDualNamespaceTests
}
/// <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>
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; an AbCip BOOL array (bit-packed
/// DWORDs, which AbCip's array encoder refuses) is not; a scalar is never flagged.</summary>
[Fact]
public void Array_write_capability_follows_the_owning_driver_and_reaches_the_uns_mirror()
{
@@ -286,7 +286,7 @@ public sealed class AddressSpaceComposerDualNamespaceTests
{
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}" },
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Boolean", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"Bool\",\"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" };
@@ -303,7 +303,7 @@ public sealed class AddressSpaceComposerDualNamespaceTests
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.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no BOOL-array encoder
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
@@ -347,4 +347,71 @@ public sealed class AddressSpaceComposerDualNamespaceTests
c.RawTags.Single(t => t.Name == "overDeviceCap").ArrayWritable.ShouldBeFalse();
c.UnsReferenceVariables.Single().BackingArrayWritable.ShouldBeFalse();
}
/// <summary>
/// #737 / #738 / #739 (review 07 U10 / U9): AbCip, AbLegacy and TwinCAT encode whole-array writes, so an
/// authored-<c>ReadWrite</c> array tag on each now materialises WRITABLE — raw node and UNS mirror alike — with
/// the ScadaBridge-shaped Int16 node type. An array already deployed read-only turns writable on the next deploy
/// through <c>ArrayWritable</c>, which rides the surgical whitelist (pinned in the classifier tests). A shape
/// each encoder refuses (an AbCip BOOL array, an AbLegacy Bit array, an AbLegacy Int array on an F file, a
/// TwinCAT BOOL bit-in-word path) stays read-only on the SAME drivers — the negative control.
/// </summary>
[Fact]
public void AbCip_AbLegacy_and_TwinCAT_arrays_materialise_writable_and_refused_shapes_stay_read_only()
{
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab", DriverType = "AbCip", DriverConfig = "{}" };
var slc = new DriverInstance { DriverInstanceId = "drv-slc", ClusterId = "c1", RawFolderId = "f", Name = "Slc", DriverType = "AbLegacy", DriverConfig = "{}" };
var tc = new DriverInstance { DriverInstanceId = "drv-tc", ClusterId = "c1", RawFolderId = "f", Name = "Tc", DriverType = "TwinCAT", DriverConfig = "{}" };
var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-ab", Name = "plc", DeviceConfig = "{}" };
var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-slc", Name = "slc", DeviceConfig = "{\"plcFamily\":\"Slc500\"}" };
var d3 = new Device { DeviceId = "d3", DriverInstanceId = "drv-tc", Name = "ads", DeviceConfig = "{}" };
static Tag Arr(string id, string device, string dataType, string tagConfig) => new()
{
TagId = id, DeviceId = device, Name = id, DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = tagConfig,
};
var tags = new[]
{
Arr("abInts", "d1", "Int16", "{\"tagPath\":\"Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
Arr("abBools", "d1", "Boolean", "{\"tagPath\":\"Bools\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}"),
Arr("slcInts", "d2", "Int16", "{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
Arr("slcBits", "d2", "Boolean", "{\"address\":\"B3:0\",\"dataType\":\"Bit\",\"isArray\":true,\"arrayLength\":4}"),
Arr("slcWrongFile", "d2", "Int16", "{\"address\":\"F8:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
Arr("tcInts", "d3", "Int16", "{\"symbolPath\":\"MAIN.Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
Arr("tcBit", "d3", "Boolean", "{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\",\"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 = "r-ab", EquipmentId = "e", TagId = "abInts" },
new UnsTagReference { UnsTagReferenceId = "r-slc", EquipmentId = "e", TagId = "slcInts" },
new UnsTagReference { UnsTagReferenceId = "r-tc", EquipmentId = "e", TagId = "tcInts" },
new UnsTagReference { UnsTagReferenceId = "r-bits", EquipmentId = "e", TagId = "slcBits" },
};
var c = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip }, new[] { ab, slc, tc }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2, d3 });
foreach (var writable in new[] { "abInts", "slcInts", "tcInts" })
{
var plan = c.RawTags.Single(t => t.Name == writable);
plan.ArrayWritable.ShouldBeTrue(writable);
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeTrue(writable);
}
foreach (var refused in new[] { "abBools", "slcBits", "slcWrongFile", "tcBit" })
{
var plan = c.RawTags.Single(t => t.Name == refused);
plan.IsArray.ShouldBeTrue(refused); // still an array node — just not a writable one
plan.ArrayWritable.ShouldBeFalse(refused);
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeFalse(refused);
}
foreach (var id in new[] { "r-ab", "r-slc", "r-tc" })
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-bits")).ShouldBeFalse();
}
}