test(ab): default AB matrices to one profile when unset; PLC-5/status-bit refusal fixture tests (#594, #593)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:45:51 -04:00
parent 3baa332d6b
commit b37e9613f7
3 changed files with 121 additions and 4 deletions
@@ -9,7 +9,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// Every AbCip compose profile binds host <c>:44818</c>, so only one can run at a time. When
/// <c>AB_SERVER_COMPOSE_PROFILE</c> is set, <see cref="Profiles"/> 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
/// <c>--plc</c> mode, so crossing every family at whichever container holds <c>:44818</c> failed the
/// non-matching rows instead of skipping them. Set <c>AB_SERVER_COMPOSE_PROFILE</c> to the running
/// profile (<c>compactlogix</c> / <c>micro800</c> / <c>guardlogix</c>) to select another family.
/// </remarks>
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 };
}
@@ -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;
/// <summary>
/// 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 (<c>T4:0.DN</c>). Each must surface as
/// <see cref="AbLegacyStatusMapper.BadNotSupported"/> and must not count as a comms fault
/// (health stays Healthy — the comms-loss watchdog reads it).
/// </summary>
/// <remarks>
/// ⚠️ <b>Which layer refuses.</b> These refusals are the DRIVER's, raised by <c>NativeAbLegacyTagRuntime</c>
/// 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.
/// </remarks>
[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);
/// <summary>A native PLC-5 non-N read is refused locally; an N read on the same driver still works.</summary>
[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);
}
/// <summary>A native PLC-5 write is refused locally before anything reaches the device.</summary>
[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);
}
/// <summary>A native status-bit sub-element write is refused locally on the families that carry T/C/R files.</summary>
/// <param name="address">The status-bit address.</param>
[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);
}
}
@@ -7,7 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <remarks>
/// Preserves the <c>AB_LEGACY_COMPOSE_PROFILE</c> filter the smoke theory always applied: when set,
/// <see cref="KnownProfiles.All"/> is narrowed to that profile <b>before</b> 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 — <b>unless</b> <c>AB_LEGACY_ENDPOINT</c> 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 <c>AB_LEGACY_COMPOSE_PROFILE</c> to pick another).
/// </remarks>
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)