Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSumReadTests.cs
T

240 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 a non-latching fallback is surfaced once per device, not once per poll.</summary>
[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]);
}
/// <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);
}
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);
}
}
}