Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSumReadTests.cs
T
Joseph Doherty af19db4ed1 feat(twincat): opt-in ADS sum-read and /hosts diagnostics bag; refresh TwinCAT backlog (#645, #612)
Sum-read (#310): batches a poll's reads per device into sum commands capped at sumReadMaxItems,
per-item ADS errors mapped per tag, falls back to individual reads when the target refuses the
command. Diagnostics (#314): ADS state, app name, online-change count, task cycle/exec/jitter via
IDriverDiagnostics. Re-implemented against the current raw-byte read path (stranded auto/driver-gaps
commits predate it). Sum-write not ported. Unit tests only; live gate pending a route.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:18:42 -04:00

238 lines
10 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>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.</summary>
[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;
}
/// <summary>Verifies that sum-read is opt-in: off, every tag is read on its own.</summary>
[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);
}
/// <summary>Verifies that one poll's tags go out as a single sum command, values in request order.</summary>
[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);
}
/// <summary>Verifies that one item's error code lands on that tag only.</summary>
[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);
}
/// <summary>Verifies the size cap: a bigger poll splits into chunks and a lone remainder reads singly.</summary>
[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
}
/// <summary>Verifies the refusal fallback: the poll still completes and the device stops asking.</summary>
[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);
}
/// <summary>Verifies a comms failure is not mistaken for a refusal: the chunk goes Bad, sum stays on.</summary>
[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]);
}
/// <summary>Verifies unknown references do not disturb result order.</summary>
[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]);
}
/// <summary>Verifies the batch carries bit index and array length so the client decodes like a single read.</summary>
[Fact]
public async Task Bulk_items_carry_array_length_and_bit_index()
{
var captured = new List<TwinCATBulkReadItem>();
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);
}
/// <summary>Verifies the JSON keys reach the options.</summary>
[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);
}
/// <summary>Verifies which overall ADS codes mean "no sum commands here" rather than a comms fault.</summary>
[Theory]
[InlineData(1792u, true)]
[InlineData(1793u, true)]
[InlineData(1794u, true)]
[InlineData(1795u, true)]
[InlineData(1797u, true)]
[InlineData(1798u, true)]
[InlineData(6u, false)] // TargetPortNotFound
[InlineData(1861u, false)] // ClientSyncTimeOut
[InlineData(1808u, false)] // symbol not found: a per-item matter
public void Overall_ads_codes_classify_as_refusal_only_for_service_errors(uint code, bool refusal) =>
AdsTwinCATClient.IsSumRefusal(code).ShouldBe(refusal);
private sealed class CapturingClient(List<TwinCATBulkReadItem> sink) : FakeTwinCATClient
{
public override Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken ct)
{
sink.AddRange(reads);
return base.ReadValuesAsync(reads, ct);
}
}
}