c86204b7d6
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
244 lines
10 KiB
C#
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);
|
|
}
|
|
}
|