Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescingDriverTests.cs
T

244 lines
10 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>Gitea #266 — driver-level behaviour of PMC range coalescing in a poll batch.</summary>
[Trait("Category", "Unit")]
public sealed class FocasPmcCoalescingDriverTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasTagDefinition Tag(string name, string addr, FocasDataType t) =>
new(name, Host, addr, t, Writable: false);
private static async Task<(FocasDriver drv, FakeFocasClient client)> StartAsync(
FocasPmcCoalesceOptions? opts, params FocasTagDefinition[] tags)
{
var client = new FakeFocasClient();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(tags),
PmcCoalesce = opts ?? new FocasPmcCoalesceOptions(),
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, client);
}
[Fact]
public async Task Adjacent_pmc_tags_are_served_by_one_range_read_and_sliced_back_per_tag()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte),
Tag("B", "R101", FocasDataType.Byte),
Tag("Bit", "R102.3", FocasDataType.Bit),
Tag("Far", "R900", FocasDataType.Byte));
c.PmcMemory[("R", 100)] = 0x11;
c.PmcMemory[("R", 101)] = 0xFF;
c.PmcMemory[("R", 102)] = 0b0000_1000;
c.Values["R900"] = (sbyte)7;
var r = await drv.ReadAsync(["A", "B", "Bit", "Far"], CancellationToken.None);
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 102)]);
r[0].Value.ShouldBe((sbyte)0x11);
r[1].Value.ShouldBe((sbyte)-1);
r[2].Value.ShouldBe(true);
r[3].Value.ShouldBe((sbyte)7); // lone tag: ordinary per-tag path, no range read of its own
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Aligned_words_decode_from_their_own_slot()
{
var (drv, c) = await StartAsync(null,
Tag("W0", "D200", FocasDataType.Int16),
Tag("W1", "D202", FocasDataType.Int16));
c.PmcMemory[("D", 200)] = 0x01; c.PmcMemory[("D", 201)] = 0x02; // 0x0102 = 258
c.PmcMemory[("D", 202)] = 0xFF; c.PmcMemory[("D", 203)] = 0xFE; // -2
var r = await drv.ReadAsync(["W0", "W1"], CancellationToken.None);
c.PmcRangeReads.ShouldBe([("D", FocasDataType.Int16, 200, 203)]);
r[0].Value.ShouldBe((short)258);
r[1].Value.ShouldBe((short)-2);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_single_pmc_tag_never_issues_a_range_read()
{
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte));
c.Values["R100"] = (sbyte)5;
var r = await drv.ReadAsync(["A"], CancellationToken.None);
c.PmcRangeReads.ShouldBeEmpty();
r[0].Value.ShouldBe((sbyte)5);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Disabling_coalescing_restores_per_tag_reads()
{
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { Enabled = false },
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.ShouldBeEmpty();
r[0].Value.ShouldBe((sbyte)1);
r[1].Value.ShouldBe((sbyte)2);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task The_span_cap_and_gap_threshold_from_options_are_honoured()
{
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { MaxSpanBytes = 2, MaxGapBytes = 0 },
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte), Tag("C", "R102", FocasDataType.Byte));
c.Values["R102"] = (sbyte)9;
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 101)]); // C is alone -> per-tag
r[2].Value.ShouldBe((sbyte)9);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_range_the_cnc_rejects_falls_back_to_per_tag_reads_with_their_own_status_and_is_remembered()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R106", FocasDataType.Byte));
c.PmcRangeRejectors.Add((_, s, e) => e - s + 1 > 2); // the spare gap bytes are not valid addresses
c.Values["R100"] = (sbyte)1;
c.Values["R106"] = (sbyte)2;
c.ReadStatuses["R106"] = FocasStatusMapper.BadNodeIdUnknown; // B is genuinely bad; A must NOT inherit that
var r1 = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r1[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
r1[0].Value.ShouldBe((sbyte)1);
r1[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
c.PmcRangeReads.Count.ShouldBe(1);
await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.Count.ShouldBe(1, "a rejected range is remembered; later polls go straight to per-tag reads");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_refusal_with_an_unmapped_rc_that_maps_to_BadCommunicationError_still_falls_back_per_tag()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.PmcRangeRejectors.Add((_, _, _) => true);
c.PmcRangeRejectStatus = FocasStatusMapper.BadCommunicationError; // what an unlisted rc / EW_PARITY / EW_SOCKET maps to
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.Select(s => s.Value).ShouldBe([(object)(sbyte)1, (sbyte)2]);
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.Count.ShouldBe(1, "a deterministic rc refusal is memoised");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_transient_busy_refusal_is_not_memoised()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
var busy = true;
c.PmcRangeRejectors.Add((_, _, _) => busy);
c.PmcRangeRejectStatus = FocasStatusMapper.BadDeviceFailure; // EW_BUSY
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
(await drv.ReadAsync(["A", "B"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
busy = false;
c.PmcMemory[("R", 100)] = 5; c.PmcMemory[("R", 101)] = 6;
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.Count.ShouldBe(2, "the range is retried once the CNC is no longer busy");
r.Select(s => s.Value).ShouldBe([(object)(sbyte)5, (sbyte)6]);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_down_device_costs_one_connect_attempt_per_batch_not_one_per_plan_and_tag()
{
var client = new FakeFocasClient { ThrowOnConnect = true };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(new[]
{
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte),
Tag("C", "R900", FocasDataType.Byte), Tag("D", "R2000", FocasDataType.Byte),
}),
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
var before = client.ConnectCount;
var r = await drv.ReadAsync(["A", "B", "C", "D"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
(client.ConnectCount - before).ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_transport_failure_fails_every_member_without_per_tag_retries()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.ThrowOnPmcRange = true;
c.Values["R100"] = (sbyte)1; // would read Good if the driver wrongly fell back
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
r.ShouldAllBe(s => s.Value == null);
c.PmcRangeReads.Count.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Result_order_follows_the_request_when_pmc_and_non_pmc_tags_are_mixed()
{
var (drv, c) = await StartAsync(null,
Tag("P", "PARAM:6711", FocasDataType.Int32),
Tag("A", "R100", FocasDataType.Byte),
Tag("B", "R101", FocasDataType.Byte));
c.Values["PARAM:6711"] = 1234;
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
var r = await drv.ReadAsync(["B", "P", "A"], CancellationToken.None);
r[0].Value.ShouldBe((sbyte)4);
r[1].Value.ShouldBe(1234);
r[2].Value.ShouldBe((sbyte)3);
c.PmcRangeReads.Count.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Duplicate_references_in_one_batch_each_get_their_value()
{
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
var r = await drv.ReadAsync(["A", "B", "A"], CancellationToken.None);
r.Select(s => s.Value).ShouldBe([(object)(sbyte)3, (sbyte)4, (sbyte)3]);
await drv.ShutdownAsync(CancellationToken.None);
}
}