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; /// /// Wire-adjacent coverage of the native transport's deliberate refusals (#571): PLC-5 non-N reads, PLC-5 /// writes and status-bit sub-element writes (T4:0.DN). Each must surface as /// and must not count as a comms fault /// (health stays Healthy — the comms-loss watchdog reads it). /// /// /// ⚠️ Which layer refuses. These refusals are the DRIVER's, raised by NativeAbLegacyTagRuntime /// before anything reaches the wire — the simulator is only needed so the driver has a device to hold a /// handle against and so the positive control (an N-file read on the same driver) can prove the refusal is /// per-tag rather than a dead connection. ab_server's PLC/5 mode covers N/F/B only, so a refusal that /// came from the simulator would look the same from the outside; the positive control and the health /// assertion are what tell the layers apart. Each test skips when its family's simulator is unreachable. /// [Collection(AbLegacyServerCollection.Name)] [Trait("Category", "Integration")] [Trait("Simulator", "ab_server-PCCC")] public sealed class AbLegacyUnsupportedPathTests(AbLegacyServerFixture sim) { private AbLegacyDriver NativeDriver(AbLegacyServerProfile profile, string id, params AbLegacyTagDefinition[] tags) { var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; return new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)], RawTags = AbLegacyRawTags.Entries(tags.Select(t => t with { DeviceHostAddress = deviceUri }).ToArray()), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, Transport = AbLegacyTransport.Native, }, driverInstanceId: id); } private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type, bool writable = false) => new(Name: name, DeviceHostAddress: "", Address: address, DataType: type, Writable: writable); /// A native PLC-5 non-N read is refused locally; an N read on the same driver still works. [AbLegacyFact] public async Task Native_Plc5_non_N_read_is_BadNotSupported_and_leaves_health_Healthy() { if (sim.SkipReasonFor(KnownProfiles.Plc5) is { } skip) Assert.Skip(skip); await using var drv = NativeDriver(KnownProfiles.Plc5, "ablegacy-plc5-unsupported-read", Tag("Float", "F8:0", AbLegacyDataType.Float), Tag("Int", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Positive control first: the connection and the handle path work, so the refusal below is per-tag. var control = await drv.ReadAsync(["Int"], TestContext.Current.CancellationToken); control.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "PLC-5 N-file read is the verified path"); var refused = await drv.ReadAsync(["Float"], TestContext.Current.CancellationToken); refused.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a driver-side refusal is not a comms fault"); // The refusal kept the connection: the N read still succeeds afterwards. (await drv.ReadAsync(["Int"], TestContext.Current.CancellationToken)).Single().StatusCode .ShouldBe(AbLegacyStatusMapper.Good); } /// A native PLC-5 write is refused locally before anything reaches the device. [AbLegacyFact] public async Task Native_Plc5_write_is_BadNotSupported_and_leaves_health_Healthy() { if (sim.SkipReasonFor(KnownProfiles.Plc5) is { } skip) Assert.Skip(skip); IntegrationWriteGate.SkipUnlessWritesAllowed(); // the write is refused, but never aim a write test at an unvetted endpoint await using var drv = NativeDriver(KnownProfiles.Plc5, "ablegacy-plc5-unsupported-write", Tag("Scratch", "N7:5", AbLegacyDataType.Int, writable: true)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var results = await drv.WriteAsync([new WriteRequest("Scratch", (short)0x1234)], TestContext.Current.CancellationToken); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); drv.GetHealth().State.ShouldNotBe(DriverState.Degraded); drv.GetHealth().State.ShouldNotBe(DriverState.Faulted); } /// A native status-bit sub-element write is refused locally on the families that carry T/C/R files. /// /// ⚠️ The fixture has NO T/C/R files (the SLC500 ab_server serves N7/F8/B3/L19 only), and that is what /// makes this test discriminating: the write is FIRST-TOUCH (nothing reads the status-bit tag beforehand), /// so a driver that created and initialised the tag's runtime before refusing would issue an init READ of a /// missing file and report BadCommunicationError + Degraded health — exactly how this test first /// failed (#593 follow-up). It passes only if the driver refuses before any wire request for the tag. /// /// The status-bit address. [AbLegacyTheory] [InlineData("T4:0.DN")] [InlineData("C5:0.CU")] [InlineData("R6:0.EN")] public async Task Native_status_bit_write_is_BadNotSupported_and_leaves_health_Healthy(string address) { if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip); IntegrationWriteGate.SkipUnlessWritesAllowed(); await using var drv = NativeDriver(KnownProfiles.Slc500, $"ablegacy-statusbit-{address}", Tag("Scratch", "N7:5", AbLegacyDataType.Int), Tag("Bit", address, AbLegacyDataType.Bit, writable: true)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Positive control: the connection is live, so a BadNotSupported below is the driver's refusal. (await drv.ReadAsync(["Scratch"], TestContext.Current.CancellationToken)).Single().StatusCode .ShouldBe(AbLegacyStatusMapper.Good); var results = await drv.WriteAsync([new WriteRequest("Bit", true)], TestContext.Current.CancellationToken); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); drv.GetHealth().State.ShouldBe(DriverState.Healthy); // The refusal left the connection usable. (await drv.ReadAsync(["Scratch"], TestContext.Current.CancellationToken)).Single().StatusCode .ShouldBe(AbLegacyStatusMapper.Good); } }