using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// /// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return, /// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only); /// otherwise compares whatever the simulator holds. /// [Collection(AbLegacyServerCollection.Name)] [Trait("Category", "Integration")] [Trait("Simulator", "ab_server-PCCC")] public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim) { /// Coalesced and per-tag reads of the same tags agree value for value. /// The simulator profile. /// The transport stack the driver runs on. [AbLegacyTheory] [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] public async Task Coalesced_reads_match_single_reads(AbLegacyServerProfile profile, AbLegacyTransport transport) { if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); var ct = TestContext.Current.CancellationToken; var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; AbLegacyTagDefinition[] tags = [ new("W0", uri, "N7:0", AbLegacyDataType.Int), new("W1", uri, "N7:1", AbLegacyDataType.Int), new("W4", uri, "N7:4", AbLegacyDataType.Int), new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit), ]; string[] refs = [.. tags.Select(t => t.Name)]; AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(uri, profile.Family)], RawTags = AbLegacyRawTags.Entries(tags), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, Transport = transport, CoalesceReads = coalesce, }, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}"); await using var single = Make(coalesce: false); await using var coalesced = Make(coalesce: true); await single.InitializeAsync("{}", ct); await coalesced.InitializeAsync("{}", ct); var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native; if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1" && Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 } && !writesRefused) { (await single.WriteAsync( [new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct)) .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good); } var a = await single.ReadAsync(refs, ct); var b = await coalesced.ReadAsync(refs, ct); a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value)); } /// /// Review 07 r0 I1, on the wire: one coalesced member past the END of the file (the fixture's N7 holds 10 /// words) makes the PLC refuse the block with a real transport status (libplctag: ERR_REMOTE_ERR). Its /// neighbours must still read Good on every poll, and the device must never be failed fast or degraded. /// /// The simulator profile. /// The transport stack the driver runs on. [AbLegacyTheory] [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] public async Task A_member_past_the_end_of_the_file_never_blacks_out_its_neighbours( AbLegacyServerProfile profile, AbLegacyTransport transport) { if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); var ct = TestContext.Current.CancellationToken; var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(uri, profile.Family)], RawTags = AbLegacyRawTags.Entries( new AbLegacyTagDefinition("A", uri, "N7:7", AbLegacyDataType.Int), new AbLegacyTagDefinition("B", uri, "N7:8", AbLegacyDataType.Int), new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, Transport = transport, }, driverInstanceId: $"ablegacy-pastend-{profile.Family}-{transport}"); await drv.InitializeAsync("{}", ct); for (var poll = 0; poll < 3; poll++) { var r = await drv.ReadAsync(["A", "B", "PastEnd"], ct); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour A"); r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour B"); r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}: the past-the-end tag"); } // The refusal (libplctag: an opaque ERR_REMOTE_ERR; native: a mapped STS) is never a comms fault: the // answering neighbours keep the driver Healthy and the device is never failed fast. drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// Review 07 r1 N1, on the wire: libplctag reports the past-the-end refusal as the OPAQUE ERR_REMOTE_ERR — /// the same code a bridge returns for a powered-off PLC behind it — so a device whose ONLY traffic is that /// reply must not look reachable: Degraded, LastSuccessfulRead frozen (the comms-loss watchdog's /// evidence). The native transport maps the STS itself and is only checked to stay non-Good. /// /// The simulator profile. /// The transport stack the driver runs on. [AbLegacyTheory] [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] public async Task An_opaque_only_device_never_proves_reachability(AbLegacyServerProfile profile, AbLegacyTransport transport) { if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); var ct = TestContext.Current.CancellationToken; var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(uri, profile.Family)], RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, Transport = transport, }, driverInstanceId: $"ablegacy-opaque-{profile.Family}-{transport}"); await drv.InitializeAsync("{}", ct); var initLastRead = drv.GetHealth().LastSuccessfulRead; await Task.Delay(20, ct); for (var poll = 0; poll < 2; poll++) (await drv.ReadAsync(["PastEnd"], ct))[0].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good); if (transport == AbLegacyTransport.Libplctag) { drv.GetHealth().State.ShouldBe(DriverState.Degraded); drv.GetHealth().LastSuccessfulRead.ShouldBe(initLastRead); } } }