using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// Gitea #645 / #310 — a poll's reads batched into ADS sum-read commands, with per-item status, /// a chunk cap and a fallback to individual reads when the target refuses the command. [Trait("Category", "Unit")] public sealed class TwinCATSumReadTests { private const string Host = "ads://5.23.91.23.1.1:851"; private static TwinCATTagDefinition Tag(string name, TwinCATDataType type = TwinCATDataType.DInt) => new(name, Host, "MAIN." + name, type); private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver( bool useSumRead, int maxItems, params TwinCATTagDefinition[] tags) { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], RawTags = TwinCATRawTags.Entries(tags), Probe = new TwinCATProbeOptions { Enabled = false }, UseSumRead = useSumRead, SumReadMaxItems = maxItems, }, "drv-sum", factory); return (drv, factory); } private static FakeTwinCATClient Seeded(params (string symbol, object value)[] values) { var c = new FakeTwinCATClient(); foreach (var (symbol, value) in values) c.Values[symbol] = value; return c; } /// Verifies that sum-read is opt-in: off, every tag is read on its own. [Fact] public async Task Disabled_reads_each_tag_individually() { var (drv, factory) = NewDriver(false, 200, Tag("A"), Tag("B"), Tag("C")); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => Seeded(("MAIN.A", 1), ("MAIN.B", 2), ("MAIN.C", 3)); var snaps = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); snaps.Select(s => s.Value).ShouldBe([1, 2, 3]); factory.Clients[0].SumReadBatches.ShouldBeEmpty(); factory.Clients[0].SingleReadCount.ShouldBe(3); } /// Verifies that one poll's tags go out as a single sum command, values in request order. [Fact] public async Task Enabled_batches_a_poll_into_one_sum_command() { var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"), Tag("C")); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => Seeded(("MAIN.A", 1), ("MAIN.B", 2), ("MAIN.C", 3)); var snaps = await drv.ReadAsync(["C", "A", "B"], CancellationToken.None); snaps.Select(s => s.Value).ShouldBe([3, 1, 2]); snaps.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good); factory.Clients[0].SumReadBatches.ShouldBe([3]); factory.Clients[0].SingleReadCount.ShouldBe(0); } /// Verifies that one item's error code lands on that tag only. [Fact] public async Task Per_item_error_code_is_mapped_to_that_tag_only() { var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"), Tag("C")); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = Seeded(("MAIN.A", 1), ("MAIN.C", 3)); c.ReadStatuses["MAIN.B"] = TwinCATStatusMapper.BadNodeIdUnknown; // ADS 1808 DeviceSymbolNotFound return c; }; var snaps = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); snaps[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good); snaps[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); snaps[1].Value.ShouldBeNull(); snaps[2].StatusCode.ShouldBe(TwinCATStatusMapper.Good); snaps[2].Value.ShouldBe(3); } /// Verifies the size cap: a bigger poll splits into chunks and a lone remainder reads singly. [Fact] public async Task Poll_larger_than_the_cap_is_split_into_chunks() { var tags = Enumerable.Range(0, 5).Select(i => Tag("T" + i)).ToArray(); var (drv, factory) = NewDriver(true, 2, tags); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = new FakeTwinCATClient(); for (var i = 0; i < 5; i++) c.Values["MAIN.T" + i] = i * 10; return c; }; var snaps = await drv.ReadAsync([.. tags.Select(t => t.Name)], CancellationToken.None); snaps.Select(s => s.Value).ShouldBe([0, 10, 20, 30, 40]); factory.Clients[0].SumReadBatches.ShouldBe([2, 2]); factory.Clients[0].SingleReadCount.ShouldBe(1); // the 5th tag: a one-item "batch" is just a read } /// Verifies the refusal fallback: the poll still completes and the device stops asking. [Fact] public async Task Refused_sum_command_falls_back_to_individual_reads_and_sticks() { var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"), Tag("C")); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = Seeded(("MAIN.A", 1), ("MAIN.B", 2), ("MAIN.C", 3)); c.SumReadException = new TwinCATSumReadRefusedException(1793); return c; }; var first = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); var second = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); first.Select(s => s.Value).ShouldBe([1, 2, 3]); first.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good); second.Select(s => s.Value).ShouldBe([1, 2, 3]); factory.Clients[0].SumReadBatches.ShouldBe([3]); // tried once, never again factory.Clients[0].SingleReadCount.ShouldBe(6); drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy); } /// Verifies a comms failure is not mistaken for a refusal: the chunk goes Bad, sum stays on. [Fact] public async Task Comms_failure_marks_the_chunk_bad_and_does_not_disable_sum_read() { var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B")); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = Seeded(("MAIN.A", 1), ("MAIN.B", 2)); c.SumReadException = new InvalidOperationException("router gone"); return c; }; var failed = await drv.ReadAsync(["A", "B"], CancellationToken.None); factory.Clients[0].SumReadException = null; var recovered = await drv.ReadAsync(["A", "B"], CancellationToken.None); failed.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.BadCommunicationError); recovered.Select(s => s.Value).ShouldBe([1, 2]); factory.Clients[0].SumReadBatches.ShouldBe([2, 2]); } /// Verifies a non-latching fallback is surfaced once per device, not once per poll. [Fact] public async Task Non_latching_fallback_warns_once_per_device() { var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B")); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 }, SimulateSumFallback = true }; await drv.ReadAsync(["A", "B"], CancellationToken.None); var state = drv.GetDeviceState(Host)!; state.SumFallbackWarned.ShouldBeTrue(); state.SumReadUnsupported.ShouldBeFalse(); // not latched: the next poll tries the sum command again await drv.ReadAsync(["A", "B"], CancellationToken.None); factory.Clients[0].SumReadBatches.ShouldBe([2, 2]); } /// Verifies unknown references do not disturb result order. [Fact] public async Task Unknown_reference_keeps_its_slot_in_a_batched_poll() { var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B")); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => Seeded(("MAIN.A", 1), ("MAIN.B", 2)); var snaps = await drv.ReadAsync(["A", "nope", "B"], CancellationToken.None); snaps[0].Value.ShouldBe(1); snaps[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); snaps[2].Value.ShouldBe(2); factory.Clients[0].SumReadBatches.ShouldBe([2]); } /// Verifies the batch carries bit index and array length so the client decodes like a single read. [Fact] public async Task Bulk_items_carry_array_length_and_bit_index() { var captured = new List(); var factory = new FakeTwinCATClientFactory { Customise = () => new CapturingClient(captured) }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], RawTags = TwinCATRawTags.Entries([ new TwinCATTagDefinition("Arr", Host, "MAIN.Arr", TwinCATDataType.Int, ArrayLength: 4), new TwinCATTagDefinition("Bit", Host, "MAIN.Word.3", TwinCATDataType.Bool), ]), Probe = new TwinCATProbeOptions { Enabled = false }, UseSumRead = true, }, "drv-sum", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["Arr", "Bit"], CancellationToken.None); captured.Count.ShouldBe(2); captured[0].ArrayCount.ShouldBe(4); captured[1].BitIndex.ShouldBe(3); } /// Verifies the JSON keys reach the options. [Fact] public void Options_parse_from_driver_config_json() { var on = TwinCATDriverFactoryExtensions.ParseOptionsForTests( """{"useSumRead": true, "sumReadMaxItems": 50}""", "d"); on.UseSumRead.ShouldBeTrue(); on.SumReadMaxItems.ShouldBe(50); var off = TwinCATDriverFactoryExtensions.ParseOptionsForTests("{}", "d"); off.UseSumRead.ShouldBeFalse(); off.SumReadMaxItems.ShouldBe(200); } private sealed class CapturingClient(List sink) : FakeTwinCATClient { public override Task> ReadValuesAsync( IReadOnlyList reads, CancellationToken ct) { sink.AddRange(reads); return base.ReadValuesAsync(reads, ct); } } }