using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// PR 2.1 — bulk read / write contract via Sum-command surface. Verifies that /// + /// bucket scalar requests by device + dispatch each bucket as a single /// / /// call. Ordering preservation, partial failure mapping, empty input + cancellation /// all live here. Per-tag fallback for bit-BOOL + whole-array tags is covered in /// / . /// [Trait("Category", "Unit")] public sealed class TwinCATSumCommandTests { private const string DevA = "ads://5.23.91.23.1.1:851"; private const string DevB = "ads://5.23.91.23.1.1:852"; private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags) { var factory = new FakeTwinCATClientFactory(); var hosts = tags.Select(t => t.DeviceHostAddress).Distinct().ToArray(); if (hosts.Length == 0) hosts = [DevA]; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [.. hosts.Select(h => new TwinCATDeviceOptions(h))], Tags = tags, Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-bulk", factory); return (drv, factory); } // ---- Bulk read ---- [Fact] public async Task Bulk_read_dispatches_single_call_per_device_bucket() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", DevA, "GVL.B", TwinCATDataType.Real), new TwinCATTagDefinition("C", DevA, "GVL.C", TwinCATDataType.Bool)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.A"] = 1, ["GVL.B"] = 2.5f, ["GVL.C"] = true }, }; var snapshots = await drv.ReadAsync(["A", "B", "C"], TestContext.Current.CancellationToken); snapshots.Count.ShouldBe(3); // One bulk-read call carrying all 3 tags — the throughput win this PR exists for. factory.Clients[0].BulkReadInvocations.Count.ShouldBe(1); factory.Clients[0].BulkReadInvocations[0].Count.ShouldBe(3); } [Fact] public async Task Bulk_read_preserves_request_order_with_mixed_outcomes() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", DevA, "GVL.Missing", TwinCATDataType.DInt), new TwinCATTagDefinition("C", DevA, "GVL.C", TwinCATDataType.String)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => { var c = new FakeTwinCATClient { Values = { ["GVL.A"] = 7, ["GVL.C"] = "ok" }, }; c.BulkReadStatuses["GVL.Missing"] = TwinCATStatusMapper.BadNodeIdUnknown; return c; }; var snapshots = await drv.ReadAsync(["A", "B", "C"], TestContext.Current.CancellationToken); snapshots[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good); snapshots[0].Value.ShouldBe(7); snapshots[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); snapshots[1].Value.ShouldBeNull(); snapshots[2].StatusCode.ShouldBe(TwinCATStatusMapper.Good); snapshots[2].Value.ShouldBe("ok"); } [Fact] public async Task Bulk_read_buckets_per_device() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt), new TwinCATTagDefinition("X", DevB, "GVL.X", TwinCATDataType.DInt), new TwinCATTagDefinition("B", DevA, "GVL.B", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.A"] = 1, ["GVL.B"] = 2, ["GVL.X"] = 99 }, }; var snapshots = await drv.ReadAsync(["A", "X", "B"], TestContext.Current.CancellationToken); snapshots[0].Value.ShouldBe(1); snapshots[1].Value.ShouldBe(99); snapshots[2].Value.ShouldBe(2); // Two clients (one per device); each bucket made a single bulk-read call. factory.Clients.Count.ShouldBe(2); factory.Clients[0].BulkReadInvocations.Count.ShouldBe(1); factory.Clients[1].BulkReadInvocations.Count.ShouldBe(1); factory.Clients.Sum(c => c.BulkReadInvocations.Sum(i => i.Count)).ShouldBe(3); } [Fact] public async Task Empty_input_returns_empty_result_without_wire_call() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var snapshots = await drv.ReadAsync([], TestContext.Current.CancellationToken); snapshots.Count.ShouldBe(0); // No client created — driver short-circuits before EnsureConnectedAsync. factory.Clients.Count.ShouldBe(0); } [Fact] public async Task Bulk_read_whole_batch_failure_marks_every_slot_BadCommunicationError() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", DevA, "GVL.B", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => new FakeTwinCATClient { ThrowOnBulkRead = true }; var snapshots = await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken); snapshots[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); snapshots[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } [Fact] public async Task Read_cancellation_propagates_through_bulk_path() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => new FakeTwinCATClient { ThrowOnBulkRead = true, Exception = new OperationCanceledException(), }; await Should.ThrowAsync( () => drv.ReadAsync(["A"], TestContext.Current.CancellationToken)); } // ---- Bulk write ---- [Fact] public async Task Bulk_write_dispatches_single_call_per_device_bucket() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", DevA, "GVL.B", TwinCATDataType.DInt), new TwinCATTagDefinition("C", DevA, "GVL.C", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var results = await drv.WriteAsync( [new WriteRequest("A", 1), new WriteRequest("B", 2), new WriteRequest("C", 3)], TestContext.Current.CancellationToken); results.Count.ShouldBe(3); results.ShouldAllBe(r => r.StatusCode == TwinCATStatusMapper.Good); factory.Clients[0].BulkWriteInvocations.Count.ShouldBe(1); factory.Clients[0].BulkWriteInvocations[0].Count.ShouldBe(3); } [Fact] public async Task Bulk_write_preserves_request_order_across_outcomes() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", DevA, "GVL.B", TwinCATDataType.DInt, Writable: false), new TwinCATTagDefinition("C", DevA, "GVL.C", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => { var c = new FakeTwinCATClient(); c.WriteStatuses["GVL.C"] = TwinCATStatusMapper.BadOutOfRange; return c; }; var results = await drv.WriteAsync( [ new WriteRequest("A", 1), new WriteRequest("B", 2), // pre-bulk reject (read-only) new WriteRequest("Unknown", 3), new WriteRequest("C", 4), // mapped per-symbol error ], TestContext.Current.CancellationToken); results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good); results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); results[2].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); results[3].StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange); } [Fact] public async Task Bulk_write_whole_batch_failure_marks_every_slot_BadCommunicationError() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", DevA, "GVL.B", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => new FakeTwinCATClient { ThrowOnBulkWrite = true }; var results = await drv.WriteAsync( [new WriteRequest("A", 1), new WriteRequest("B", 2)], TestContext.Current.CancellationToken); results[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); } [Fact] public async Task Empty_write_input_returns_empty_result_without_wire_call() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", DevA, "GVL.A", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var results = await drv.WriteAsync([], TestContext.Current.CancellationToken); results.Count.ShouldBe(0); factory.Clients.Count.ShouldBe(0); } }