using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// Gitea #266 — driver-level behaviour of PMC range coalescing in a poll batch. [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); } }