52708bff52
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list writes (its element types are only Boolean/Int32/Float/Double/String/ DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a Double list to a Float array. EvaluateEquipmentWriteStructure now converts a numeric array of another numeric element type element-wise, CHECKED, into the node's element type and hands the converted array back through OnEquipmentTagWrite's ref value, so the node stores and the driver receives the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to- integer or Float-overflowing element refuses the whole write BadTypeMismatch (nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays closed. Non-numeric mismatches stay refused. The wrong 'no client relies on a looser rule' comment is corrected. MINOR-1: the array-value-to-scalar-node refusal broke writes back to browse-committed array tags (committed scalar, isArray never set) on pass-through drivers. It now applies only when the owning driver's scalar encoder cannot take an array (DriverArrayWriteSupport. RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan. ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue -> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the classifier whitelist (driver-type edit rebuilds); a mirror whose flag changes is recreated, not updated in place. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
361 lines
20 KiB
C#
361 lines
20 KiB
C#
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 const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint";
|
|
private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe";
|
|
private const string UaScalarRaw = "Plant/Ua1/up/Vector";
|
|
|
|
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 gx = new DriverInstance
|
|
{
|
|
DriverInstanceId = "drv-gx", ClusterId = "c1", RawFolderId = "raw-plant",
|
|
Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
|
|
};
|
|
var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" };
|
|
// An upstream array variable committed from the /raw browser: the commit sets the DataType but never
|
|
// isArray, so it materialises SCALAR while the driver publishes an array into it (R6 MINOR-1).
|
|
var ua = new DriverInstance
|
|
{
|
|
DriverInstanceId = "drv-ua", ClusterId = "c1", RawFolderId = "raw-plant",
|
|
Name = "Ua1", DriverType = "OpcUaClient", DriverConfig = "{}",
|
|
};
|
|
var uaDevice = new Device { DeviceId = "dev-4", DriverInstanceId = "drv-ua", Name = "up", DeviceConfig = "{}" };
|
|
var vector = new Tag
|
|
{
|
|
TagId = "t-vector", DeviceId = "dev-4", Name = "Vector", DataType = "Int32",
|
|
AccessLevel = TagAccessLevel.ReadWrite,
|
|
TagConfig = "{\"nodeId\":\"ns=2;s=Vector\"}",
|
|
};
|
|
var recipe = new Tag
|
|
{
|
|
TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32",
|
|
AccessLevel = TagAccessLevel.ReadWrite,
|
|
TagConfig = "{\"FullName\":\"Mixer_001.Recipe\",\"isArray\":true,\"arrayLength\":2}",
|
|
};
|
|
var setpoint = new Tag
|
|
{
|
|
TagId = "t-setpoint", DeviceId = "dev-1", Name = "Setpoint", DataType = "Int16",
|
|
AccessLevel = TagAccessLevel.ReadWrite,
|
|
TagConfig = "{\"address\":\"DB1.DBW20\",\"dataType\":\"Int16\"}",
|
|
};
|
|
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, gx, ua], Array.Empty<ScriptedAlarm>(),
|
|
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint, vector],
|
|
rawFolders: [folder], devices: [plc1, plc2, gxDevice, uaDevice]);
|
|
|
|
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);
|
|
rig.Sink.WriteValue(GalaxyArrayRaw, new[] { 11, 12 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
|
rig.Sink.WriteValue(S7ScalarRaw, (short)5, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
|
rig.Sink.WriteValue(UaScalarRaw, new[] { 1, 2 }, 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an <c>OnWriteValue</c> handler,
|
|
/// so the node manager must refuse an array its node type cannot represent before dispatch: an <c>Int32[]</c>
|
|
/// element outside Int16 to an S7 Int16 array, and a <c>UInt32[]</c> element above Int32.MaxValue to a Galaxy
|
|
/// Int32 array (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as
|
|
/// -294,967,296, Good).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await StartAsync("array-type", ct);
|
|
var gateway = new RecordingWriteGateway(succeed: true);
|
|
rig.Nm.NodeWriteGateway = gateway;
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var s7 = Raw(session, S7ArrayRaw);
|
|
var galaxy = Raw(session, GalaxyArrayRaw);
|
|
|
|
(await ReadAccessLevelAsync(session, galaxy, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
|
|
|
|
(await WriteAsync(session, s7, new[] { 1, 2, 3, 70_000 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "an Int32 element outside Int16");
|
|
(await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array");
|
|
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
|
|
((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 });
|
|
gateway.Count.ShouldBe(0);
|
|
|
|
// Positive control: the correctly-typed array on the same Galaxy node is dispatched as-is.
|
|
(await WriteAsync(session, galaxy, new[] { 21, 22 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
|
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 21, 22 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// #610 review R6 MAJOR-1 — ScadaBridge writes the CLR list a List attribute decodes to (here Int32, the only
|
|
/// integer element type it has) to an Int16 array node. The write is converted, checked, to the node's
|
|
/// element type: the client sees Good, the node serves <c>short[]</c>, and the driver receives <c>short[]</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_numeric_array_of_another_element_type_is_converted_to_the_node_type()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await StartAsync("array-convert", ct);
|
|
var gateway = new RecordingWriteGateway(succeed: true);
|
|
rig.Nm.NodeWriteGateway = gateway;
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var s7 = Raw(session, S7ArrayRaw);
|
|
|
|
(await WriteAsync(session, s7, new[] { 10, -20, 30, 32_767 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
|
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 10, -20, 30, 32_767 });
|
|
lock (gateway.Writes)
|
|
{
|
|
var dispatched = gateway.Writes.ShouldHaveSingleItem().Value.ShouldBeOfType<short[]>();
|
|
dispatched.ShouldBe(new short[] { 10, -20, 30, 32_767 });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #610 review R6 MINOR-1 — a pass-through driver's scalar-shaped node (an upstream array variable committed
|
|
/// from <c>/raw</c> without <c>isArray</c>) still dispatches an array write, as it did before #610: the
|
|
/// upstream server arbitrates the shape. The S7 scalar's refusal is the other side of the same rule.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_array_value_to_a_pass_through_scalar_node_is_still_dispatched()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await StartAsync("array-to-ua-scalar", ct);
|
|
var gateway = new RecordingWriteGateway(succeed: true);
|
|
rig.Nm.NodeWriteGateway = gateway;
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
|
|
(await WriteAsync(session, Raw(session, UaScalarRaw), new[] { 7, 8, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
|
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 7, 8, 9 });
|
|
|
|
(await WriteAsync(session, Raw(session, S7ScalarRaw), new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
|
gateway.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node of a driver whose scalar encoder cannot take
|
|
/// one (S7) is refused before dispatch.</summary>
|
|
[Fact]
|
|
public async Task An_array_value_to_a_scalar_node_is_refused_inline()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await StartAsync("array-to-scalar", ct);
|
|
var gateway = new RecordingWriteGateway(succeed: true);
|
|
rig.Nm.NodeWriteGateway = gateway;
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var scalar = Raw(session, S7ScalarRaw);
|
|
|
|
(await WriteAsync(session, scalar, new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
|
((short)(await session.ReadValueAsync(scalar, ct)).Value).ShouldBe((short)5);
|
|
gateway.Count.ShouldBe(0);
|
|
|
|
// Positive control: a scalar write to the same node is dispatched.
|
|
(await WriteAsync(session, scalar, (short)9, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
|
gateway.Count.ShouldBe(1);
|
|
}
|
|
}
|