diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs index 55b12b85..9a3f5fcf 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs @@ -9,7 +9,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// Every AbCip compose profile binds host :44818, so only one can run at a time. When /// AB_SERVER_COMPOSE_PROFILE is set, keeps only that profile, so the /// rows match the running container instead of driving, say, Micro800 semantics at a ControlLogix. -/// Unset, every profile is crossed (the original behaviour). +/// Unset, only the default ControlLogix profile is crossed: ab_server does not self-identify its +/// --plc mode, so crossing every family at whichever container holds :44818 failed the +/// non-matching rows instead of skipping them. Set AB_SERVER_COMPOSE_PROFILE to the running +/// profile (compactlogix / micro800 / guardlogix) to select another family. /// public static class TransportMatrix { @@ -24,8 +27,9 @@ public static class TransportMatrix { var only = Environment.GetEnvironmentVariable("AB_SERVER_COMPOSE_PROFILE"); return from p in KnownProfiles.All - where string.IsNullOrEmpty(only) || - string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase) + where string.Equals(p.ComposeProfile, + string.IsNullOrEmpty(only) ? KnownProfiles.ControlLogix.ComposeProfile : only, + StringComparison.OrdinalIgnoreCase) from t in AllTransports select new object[] { p, t }; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs new file mode 100644 index 00000000..7f04d749 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs @@ -0,0 +1,107 @@ +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 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); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs index fbfdc7d1..eefe2934 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs @@ -7,7 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// /// Preserves the AB_LEGACY_COMPOSE_PROFILE filter the smoke theory always applied: when set, /// is narrowed to that profile before it is crossed with the -/// transports, so the rows match the running container. +/// transports, so the rows match the running container. Unset, every family is crossed because each +/// has its own port and skips itself when unreachable — unless AB_LEGACY_ENDPOINT pins +/// one device, in which case the family is a property of that device and cannot be detected, so only +/// the default SLC500 profile is crossed (set AB_LEGACY_COMPOSE_PROFILE to pick another). /// public static class TransportMatrix { @@ -21,6 +24,9 @@ public static class TransportMatrix get { var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); + if (string.IsNullOrEmpty(only) && + Environment.GetEnvironmentVariable("AB_LEGACY_ENDPOINT") is { Length: > 0 }) + only = KnownProfiles.Slc500.ComposeProfile; return from p in KnownProfiles.All where string.IsNullOrEmpty(only) || string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase)