using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// Review 07 P7: adjacent N/B/F/L elements of one device were never coalesced (one PCCC round trip per tag), /// although the array path proves the transport can read a span. Scalar tags near each other on one file now /// ride ONE multi-element read and are decoded from their offsets — with exactly the CLR types the single-tag /// path produces — and a block the device refuses falls back to per-tag reads. /// [Trait("Category", "Unit")] public sealed class AbLegacyReadCoalescingTests { private const string Host = "ab://10.0.0.5/1,0"; private static (AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory, List Created) NewDriver( Func make, bool coalesce, params AbLegacyTagDefinition[] tags) { var created = new List(); var factory = new FakeAbLegacyTagFactory { Customise = p => { lock (created) created.Add(p); return make(p); }, }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(Host)], RawTags = AbLegacyRawTags.Entries(tags), Probe = new AbLegacyProbeOptions { Enabled = false }, CoalesceReads = coalesce, }, "drv-coalesce", factory); return (drv, factory, created); } private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type) => new(name, Host, address, type); [Fact] public async Task Nearby_N_file_words_and_bits_ride_one_read_and_decode_like_single_reads() { var (drv, _, created) = NewDriver( p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 10, 11, 12, 4, 14, -15 } }, coalesce: true, Tag("W0", "N7:0", AbLegacyDataType.Int), Tag("W1", "N7:1", AbLegacyDataType.AnalogInt), Tag("B32", "N7:3/2", AbLegacyDataType.Bit), Tag("B31", "N7:3/1", AbLegacyDataType.Bit), Tag("W5", "N7:5", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["W0", "W1", "B32", "B31", "W5"], CancellationToken.None); r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); r[0].Value.ShouldBe(10); // int, exactly as DecodeValue(Int) returns r[1].Value.ShouldBe(11); r[2].Value.ShouldBe(true); // word 3 = 0b100: bit 2 set r[3].Value.ShouldBe(false); r[4].Value.ShouldBe(-15); created.Count.ShouldBe(1); created[0].TagName.ShouldBe("N7:0"); created[0].ElementCount.ShouldBe(6); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Float_and_long_files_coalesce_at_their_element_width() { var (drv, _, created) = NewDriver( p => p.TagName.StartsWith('F') ? new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 0f, 2.5f } } : new FakeAbLegacyTag(p) { ArrayValue = new[] { 7, int.MinValue } }, coalesce: true, Tag("F0", "F8:0", AbLegacyDataType.Float), Tag("F2", "F8:2", AbLegacyDataType.Float), Tag("L0", "L9:0", AbLegacyDataType.Long), Tag("L1b31", "L9:1/31", AbLegacyDataType.Bit)); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["F0", "F2", "L0", "L1b31"], CancellationToken.None); r.Select(s => s.Value).ShouldBe(new object[] { 1.5f, 2.5f, 7, true }); created.Select(c => (c.TagName, c.ElementCount)).OrderBy(x => x.TagName) .ShouldBe(new[] { ("F8:0", 3), ("L9:0", 2) }); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Far_apart_or_ineligible_tags_keep_the_single_tag_path() { var (drv, _, created) = NewDriver( p => new FakeAbLegacyTag(p) { Value = 1 }, coalesce: true, Tag("Near", "N7:0", AbLegacyDataType.Int), Tag("Far", "N7:40", AbLegacyDataType.Int), // gap > MaxCoalesceGap Tag("Mistyped", "N7:1", AbLegacyDataType.Float), // width mismatch: single path answers as before Tag("Acc", "T4:0.ACC", AbLegacyDataType.Int), // sub-element Tag("Status", "S:1", AbLegacyDataType.Int), // no file number Tag("Ind", "N7:[N7:0]", AbLegacyDataType.Int)); // indirect await drv.InitializeAsync("{}", CancellationToken.None); _ = await drv.ReadAsync(["Near", "Far", "Mistyped", "Acc", "Status", "Ind"], CancellationToken.None); created.ShouldAllBe(c => c.ElementCount == 1); created.Select(c => c.TagName).ShouldContain("N7:40"); created.Select(c => c.TagName).ShouldContain("T4:0.ACC"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task A_refused_block_falls_back_to_single_reads_and_is_remembered_after_a_repeat() { // N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is. var (drv, _, created) = NewDriver( p => p.ElementCount > 1 || p.TagName == "N7:9" ? new FakeAbLegacyTag(p) { Status = (int)AbStatus.OutOfBounds } : new FakeAbLegacyTag(p) { Value = 3 }, coalesce: true, Tag("A", "N7:0", AbLegacyDataType.Int), Tag("B", "N7:2", AbLegacyDataType.Int), Tag("C", "N7:9", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); for (var poll = 0; poll < 3; poll++) { var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds)); } // Refused on polls 0 and 1 (each falling back to singles in the same pass); remembered after the repeat, // so poll 2 does not try the block again. created.Count(c => c.ElementCount > 1).ShouldBe(2); drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task A_block_comms_fault_fails_its_members_together_and_degrades() { var (drv, _, _) = NewDriver( p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new System.Net.Sockets.SocketException(10054) }, coalesce: true, Tag("A", "N7:0", AbLegacyDataType.Int), Tag("B", "N7:1", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["A", "B"], CancellationToken.None); r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task CoalesceReads_false_reads_every_tag_singly() { var (drv, _, created) = NewDriver( p => new FakeAbLegacyTag(p) { Value = 1 }, coalesce: false, Tag("A", "N7:0", AbLegacyDataType.Int), Tag("B", "N7:1", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["A", "B"], CancellationToken.None); r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); created.Count.ShouldBe(2); created.ShouldAllBe(c => c.ElementCount == 1); await drv.ShutdownAsync(CancellationToken.None); } [Theory] [InlineData("""{"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)] [InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)] [InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)] public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) => (AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads != false).ShouldBe(expected); }