using System.Collections.Concurrent; using System.Linq; using System.Threading.Tasks; using Opc.Ua; using Opc.Ua.Client; 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 { /// /// Verifies OPC UA indexed array writes against the bridge's whole-array runtime update behavior. /// public class ArrayWriteTests { /// /// Confirms that writing a single array element updates the correct slot while preserving the rest of the array. /// [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(); } } /// /// Confirms that array nodes use bracketless OPC UA node identifiers while still exposing one-dimensional array metadata. /// [Fact] public async Task ArrayNode_UsesBracketlessNodeId_AndPublishesArrayDimensions() { 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); var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); var value = client.Read(nodeId).Value as string[]; value.ShouldNotBeNull(); value.Length.ShouldBe(50); var valueRank = client.ReadAttribute(nodeId, Attributes.ValueRank).Value; valueRank.ShouldBe(ValueRanks.OneDimension); var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[]; dimensions.ShouldNotBeNull(); dimensions.ShouldBe(new uint[] { 50 }); } finally { await fixture.DisposeAsync(); } } /// /// Confirms that a null runtime value for a statically sized array is exposed as a typed fixed-length array. /// [Fact] public async Task Read_NullStaticArray_ReturnsDefaultTypedArray() { var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null); await fixture.InitializeAsync(); try { using var client = new OpcUaTestClient(); await client.ConnectAsync(fixture.EndpointUrl); var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); var value = client.Read(nodeId).Value as string[]; value.ShouldNotBeNull(); value.Length.ShouldBe(50); value.ShouldAllBe(v => v == string.Empty); var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[]; dimensions.ShouldBe(new uint[] { 50 }); } finally { await fixture.DisposeAsync(); } } /// /// Confirms that an indexed write also updates the published OPC UA value seen by subscribed clients. /// [Fact] public async Task Write_SingleArrayElement_PublishesUpdatedArrayToSubscribers() { 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); var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); var notifications = new ConcurrentBag(); var (sub, item) = await client.SubscribeAsync(nodeId, intervalMs: 100); item.Notification += (_, e) => { if (e.NotificationValue is MonitoredItemNotification notification) notifications.Add(notification); }; await Task.Delay(500); var status = client.Write(nodeId, new[] { "UPDATED-PART" }, indexRange: "1"); StatusCode.IsGood(status).ShouldBe(true); await Task.Delay(1000); notifications.Any(n => n.Value.Value is string[] values && values.Length == 50 && values[0] == "PART-00" && values[1] == "UPDATED-PART" && values[2] == "PART-02").ShouldBe(true); await sub.DeleteAsync(true); } finally { await fixture.DisposeAsync(); } } /// /// Confirms that indexed writes succeed even when the current runtime array value is null. /// [Fact] public async Task Write_SingleArrayElement_WhenCurrentArrayIsNull_UsesDefaultArray() { var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null); await fixture.InitializeAsync(); try { using var client = new OpcUaTestClient(); await client.ConnectAsync(fixture.EndpointUrl); var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); 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(string.Empty); after[1].ShouldBe("UPDATED-PART"); after[2].ShouldBe(string.Empty); } finally { await fixture.DisposeAsync(); } } } }