diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index a2406255..c8bd3afa 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -431,11 +431,37 @@ public class InstanceActor : ReceiveActor return; } + // MV (C1): for a data-sourced List attribute the incoming command.Value is + // the canonical JSON array string (ScopeAccessors encodes the script's + // List for transport/storage). Writing that string straight to the DCL + // would push a String scalar to an array node. Decode it back to a typed + // List so the DCL/Variant write produces a real array. A non-empty value + // that fails to decode (malformed JSON / bad element) is poison — reject the + // write rather than forward garbage to the device (mirrors the static-path + // rejection in HandleSetStaticAttributeCore). Scalars are unchanged. + object? writeValue = command.Value; + if (IsListAttribute(resolved) && !string.IsNullOrWhiteSpace(command.Value)) + { + var decoded = DecodeAttributeValue(resolved, command.Value); + if (decoded == null) + { + _logger.LogWarning( + "SetAttribute rejected — value for data-sourced List attribute '{Attribute}' on instance '{Instance}' is not a valid list", + attributeName, instanceName); + caller.Tell(new SetStaticAttributeResponse( + correlationId, instanceName, attributeName, false, + $"Invalid list value for attribute '{attributeName}'", DateTimeOffset.UtcNow)); + return; + } + + writeValue = decoded; + } + var writeRequest = new WriteTagRequest( correlationId, resolved.BoundDataConnectionName!, resolved.DataSourceReference!, - command.Value, + writeValue, DateTimeOffset.UtcNow); // Ask the DCL and pipe the result back to the original caller. The DCL diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs index c3babe72..ab888819 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs @@ -1,20 +1,44 @@ +using System.Collections.Generic; using Opc.Ua; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// -/// MV-8: the OPC UA WRITE path () -/// wraps the outgoing value in new Variant(value) and lets the OPC -/// Foundation SDK serialize it. For a structured multi-value (List) attribute -/// the value handed down is a CLR array. These tests assert that the load-bearing -/// step — wrapping an array in a — succeeds without -/// throwing, which is what the write path relies on (no separate array handling -/// is required in our code). A full device round-trip needs a live server and is -/// covered by the live OPC UA browse/read smoke tests. +/// SCOPE: these tests cover ONLY the SDK-level building block the write path +/// relies on — that new Variant(collection) wraps a CLR array / list as a +/// typed array (ValueRank = OneDimension) without throwing. +/// They are NOT an end-to-end test of the runtime write flow: they feed a +/// hand-built collection straight into , bypassing the +/// InstanceActor decode step that produces that collection. +/// +/// The END-TO-END flow — a script's canonical JSON list string being DECODED to a +/// typed List<T> before the WriteTagRequest reaches the DCL +/// (so OPC UA writes an array node, not a String scalar) — is covered by +/// InstanceActorTests.InstanceActor_DataSourcedListWrite_SendsTypedArrayToDcl_NotJsonString. +/// The runtime hands a +/// List<T> (the codec's decode result), which the SDK wraps +/// identically to a CLR array — see the List<int> case below. A full +/// device round-trip needs a live server and is covered by the live OPC UA smoke +/// tests. /// [Trait("Category", "Unit")] public class RealOpcUaClientArrayWriteTests { + [Fact] + public void Variant_wraps_int_list_as_array_without_throwing() + { + // The runtime actually hands WriteValueAsync a List (the decode result), + // not a raw T[]; assert the SDK wraps it as a typed array all the same. + var value = new List { 10, 20, 30 }; + + var ex = Record.Exception(() => new Variant(value)); + + Assert.Null(ex); + var variant = new Variant(value); + Assert.Equal(BuiltInType.Int32, variant.TypeInfo.BuiltInType); + Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank); + } + [Fact] public void Variant_wraps_int_array_without_throwing() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index 4c18004b..f93a238e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -660,6 +660,94 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal(1450, response.Value); } + /// + /// MV (C1 fix): a WRITE to a data-sourced DataType.List attribute must + /// send the DCL a TYPED collection (so OPC UA writes an array node), NOT the + /// canonical JSON string the script layer produced. The script path encodes + /// List<int> to "[10,20,30]"; HandleSetDataAttribute must + /// decode that back to a typed List<int> before building the + /// WriteTagRequest. We assert the captured WriteTagRequest.Value is the typed + /// list {10,20,30} — never the string "[10,20,30]". + /// + [Fact] + public void InstanceActor_DataSourcedListWrite_SendsTypedArrayToDcl_NotJsonString() + { + const string tag = "ns=3;s=Pump.Setpoints"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-ListWrite", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Setpoints", Value = null, + DataType = "List", ElementDataType = "Int32", + DataSourceReference = tag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("Pump-ListWrite", config, dcl); + + // Script-style write: ScopeAccessors (AttributeValueCodec.Encode) has + // already encoded the script's List to the canonical JSON array string, + // which is an array of element STRINGS (not raw JSON numbers). + actor.Tell(new SetStaticAttributeCommand( + "corr-write", "Pump-ListWrite", "Setpoints", "[\"10\",\"20\",\"30\"]", DateTimeOffset.UtcNow)); + + // The DCL must receive a WriteTagRequest carrying a TYPED collection. + var write = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("PLC", write.ConnectionName); + Assert.Equal(tag, write.TagPath); + Assert.IsNotType(write.Value); + var list = Assert.IsType>(write.Value); + Assert.Equal(new[] { 10, 20, 30 }, list); + + // Complete the Ask so the actor replies success to the caller. + dcl.Reply(new WriteTagResponse("corr-write", true, null, DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(response.Success); + } + + /// + /// MV (C1 fix): a malformed value written to a data-sourced List attribute + /// must be REJECTED before reaching the DCL — Success=false and NO + /// WriteTagRequest is forwarded (mirrors the static-path malformed rejection). + /// + [Fact] + public void InstanceActor_DataSourcedListWrite_Malformed_Rejected_NoDclWrite() + { + const string tag = "ns=3;s=Pump.Bad"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-ListWriteBad", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Setpoints", Value = null, + DataType = "List", ElementDataType = "Int32", + DataSourceReference = tag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("Pump-ListWriteBad", config, dcl); + + // Malformed JSON (unterminated array, non-int element) → reject the write. + actor.Tell(new SetStaticAttributeCommand( + "corr-bad-write", "Pump-ListWriteBad", "Setpoints", "[\"a\"", DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.False(response.Success); + Assert.NotNull(response.ErrorMessage); + + // No write must reach the DCL. + dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + private void ExpectNoTerminated(IActorRef actor, TimeSpan within) { // The actor is Watch()ed; assert no Terminated arrives in the window.