From 3f813b3869118c1f62fb22d13112d674890d091a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Mar 2026 11:05:04 -0400 Subject: [PATCH] Add OPC UA array element write integration test --- .../OpcUa/LmxNodeManager.cs | 62 ++++++++++++++++++- .../Helpers/FakeMxAccessClient.cs | 2 + .../Helpers/OpcUaTestClient.cs | 21 +++++++ .../Integration/ArrayWriteTests.cs | 50 +++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs index 210a82f..be11ee6 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs @@ -363,7 +363,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { try { - var value = nodesToWrite[i].Value.WrappedValue.Value; + var writeValue = nodesToWrite[i]; + var value = writeValue.Value.WrappedValue.Value; + + if (!string.IsNullOrWhiteSpace(writeValue.IndexRange)) + { + if (!TryApplyArrayElementWrite(tagRef, value, writeValue.IndexRange, out var updatedArray)) + { + errors[i] = new ServiceResult(StatusCodes.BadIndexRangeInvalid); + continue; + } + + value = updatedArray; + } + var success = _mxAccessClient.WriteAsync(tagRef, value).GetAwaiter().GetResult(); errors[i] = success ? ServiceResult.Good : new ServiceResult(StatusCodes.BadInternalError); } @@ -376,6 +389,53 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } } + private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, out object updatedArray) + { + updatedArray = null!; + + if (!int.TryParse(indexRange, out var index) || index < 0) + return false; + + var currentValue = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult().Value; + if (currentValue is not Array currentArray || currentArray.Rank != 1 || index >= currentArray.Length) + return false; + + var nextArray = (Array)currentArray.Clone(); + var elementType = currentArray.GetType().GetElementType(); + if (elementType == null) + return false; + + var normalizedValue = NormalizeIndexedWriteValue(writeValue); + nextArray.SetValue(ConvertArrayElementValue(normalizedValue, elementType), index); + updatedArray = nextArray; + return true; + } + + private static object? NormalizeIndexedWriteValue(object? value) + { + if (value is Array array && array.Length == 1) + return array.GetValue(0); + return value; + } + + private static object? ConvertArrayElementValue(object? value, Type elementType) + { + if (value == null) + { + if (elementType.IsValueType) + return Activator.CreateInstance(elementType); + return null; + } + + if (elementType.IsInstanceOfType(value)) + return value; + + if (elementType == typeof(string)) + return value.ToString(); + + return Convert.ChangeType(value, elementType); + } + #endregion #region Subscription Delivery diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeMxAccessClient.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeMxAccessClient.cs index 46bd3a0..6c478d3 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeMxAccessClient.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeMxAccessClient.cs @@ -55,6 +55,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers public Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default) { WrittenValues.Add((fullTagReference, value)); + if (WriteResult) + TagValues[fullTagReference] = Vtq.Good(value); return Task.FromResult(WriteResult); } diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs index 7d6ec60..dc14767 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs @@ -114,6 +114,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers return Session.ReadValue(nodeId); } + /// + /// Write a node's value, optionally using an OPC UA index range for array element writes. + /// Returns the server status code for the write. + /// + public StatusCode Write(NodeId nodeId, object value, string? indexRange = null) + { + var nodesToWrite = new WriteValueCollection + { + new WriteValue + { + NodeId = nodeId, + AttributeId = Attributes.Value, + IndexRange = indexRange, + Value = new DataValue(new Variant(value)) + } + }; + + Session.Write(null, nodesToWrite, out var results, out _); + return results[0]; + } + /// /// Create a subscription with a monitored item on the given node. /// Returns the subscription and monitored item for inspection. diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs new file mode 100644 index 0000000..9bbf499 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs @@ -0,0 +1,50 @@ +using System.Linq; +using System.Threading.Tasks; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; +using ZB.MOM.WW.LmxOpcUa.Tests.Helpers; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration +{ + public class ArrayWriteTests + { + [Fact] + public async Task Write_SingleArrayElement_UpdatesWholeArrayValue() + { + var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); + fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good( + Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray()); + + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + // Browse path: TestMachine_001 -> MESReceiver -> MoveInPartNumbers + var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers[]"); + + var before = client.Read(nodeId).Value as string[]; + before.ShouldNotBeNull(); + before.Length.ShouldBe(50); + before[1].ShouldBe("PART-01"); + + var status = client.Write(nodeId, new[] { "UPDATED-PART" }, indexRange: "1"); + StatusCode.IsGood(status).ShouldBe(true); + + var after = client.Read(nodeId).Value as string[]; + after.ShouldNotBeNull(); + after.Length.ShouldBe(50); + after[0].ShouldBe("PART-00"); + after[1].ShouldBe("UPDATED-PART"); + after[2].ShouldBe("PART-02"); + } + finally + { + await fixture.DisposeAsync(); + } + } + } +}