fix(ab): Task 22 review r1 — release-note timing and AbLegacy regressions, probe E2E on both transports

- I1: the upgrade restart itself moves every no-key AbCip/AbLegacy driver to Native (every process
  start constructs drivers, incl. boot-from-cache). Docs + CLAUDE.md now say so and tell operators
  to set "transport": "Libplctag" BEFORE upgrading (older builds ignore the unknown key).
- I2: AbLegacy release note leads with the status-bit write refusal (T4:0.DN, C5:0.CU, R6:0.EN, ...)
  on defaulted SLC/MicroLogix drivers and the fallback. The real client status is
  BadCommunicationError, not BadNotSupported (the driver's write catch-all maps the runtime's
  AbTransportException like any transport failure, as #571 does for PLC-5), with handle eviction
  and Degraded health; pinned by a driver-level test (no 0xAA write on the wire, with a positive
  control). ST string byte order stated as unverified. Also corrects the docs' "T4:0/DN" examples:
  the driver's address grammar is "T4:0.DN" ("/DN" is a malformed address).
- I3: DriverProbeHandshakeE2eTests' two AbCip probe tests are a [Theory] over Native and
  Libplctag. Run against ab_server controllogix: all 4 rows pass with identical exact messages.
- M1: documented (docs + resolver XML doc): the AdminUI merges only the probed device, so a
  mixed-family defaulted AbLegacy driver can probe Native while running Libplctag.
- M2: factory ParseEnum rejects undefined integer strings ("7") via Enum.IsDefined (both drivers).
- M3: AbCip driver-level bad value reads "AB CIP config in '<id>' has unknown Transport ..."; the
  named constant is gone (tagName: null now means driver-level).
- M4: the explicit-Native limited-family Warning is once per instance, not per (re)init.
- Nit: EffectiveTransport docs note that injected factories bypass it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 21:06:40 -04:00
parent ed0881d672
commit 1806af54f0
13 changed files with 211 additions and 34 deletions
@@ -66,6 +66,28 @@ public sealed class AbCipTransportSwitchTests
existing.Message.ShouldContain("unknown PlcFamily 'Bogus'");
}
/// <summary>
/// An undefined integer string is rejected, not silently run on a switch's default arm (Task 22 review
/// r1 M2): <c>Enum.TryParse</c> alone accepts "7". A defined numeric ("1") still parses, as before.
/// </summary>
[Fact]
public void An_undefined_numeric_transport_is_rejected()
{
Should.Throw<InvalidOperationException>(
() => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\"")))
.Message.ShouldContain("unknown Transport '7'");
AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbCipTransport.Native);
}
/// <summary>A bad driver-level value names the driver config, not a "tag" (Task 22 review r1 M3).</summary>
[Fact]
public void A_bad_transport_names_the_driver_config()
{
var ex = Should.Throw<InvalidOperationException>(
() => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\"")));
ex.Message.ShouldStartWith("AB CIP config in 'ab1' has unknown Transport 'Bogus'");
}
/// <summary>An explicitly injected factory wins over the option.</summary>
[Fact]
public void Injected_factory_wins_over_the_option()
@@ -48,6 +48,34 @@ public sealed class AbLegacyTransportInitLogTests
if (warns) warnings[0].Message.ShouldContain(family);
}
/// <summary>
/// The Warning is once per INSTANCE, not per init: every comms-loss reconnect retry re-runs init
/// (Task 22 review r1 M4). Three inits, one warning.
/// </summary>
[Fact]
public async Task The_limited_family_warning_is_logged_once_per_instance_across_reinits()
{
var config = Config("Native", "Plc5");
var captured = new List<(LogLevel, string)>();
using var factory = new CapturingLoggerFactory(captured);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", config, factory);
try
{
await driver.InitializeAsync(config, TestContext.Current.CancellationToken);
await driver.ReinitializeAsync(config, TestContext.Current.CancellationToken);
await driver.ReinitializeAsync(config, TestContext.Current.CancellationToken);
}
finally
{
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
}
List<(LogLevel Level, string Message)> snapshot;
lock (captured) snapshot = [.. captured];
snapshot.Count(m => m.Message.Contains("initializing")).ShouldBe(3); // positive control: three inits ran
snapshot.Count(m => m.Level == LogLevel.Warning && m.Message.Contains("native support is limited")).ShouldBe(1);
}
private static async Task<List<(LogLevel Level, string Message)>> InitAndCaptureAsync(string config)
{
var captured = new List<(LogLevel, string)>();
@@ -96,6 +96,19 @@ public sealed class AbLegacyTransportSwitchTests
existing.Message.ShouldContain("unknown PlcFamily 'Bogus'");
}
/// <summary>
/// An undefined integer string is rejected, not silently run on a switch's default arm (Task 22 review
/// r1 M2): <c>Enum.TryParse</c> alone accepts "7". A defined numeric ("1") still parses, as before.
/// </summary>
[Fact]
public void An_undefined_numeric_transport_is_rejected()
{
Should.Throw<InvalidOperationException>(
() => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\"")))
.Message.ShouldContain("unknown Transport '7'");
AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbLegacyTransport.Native);
}
/// <summary>An explicitly injected factory wins over the option.</summary>
[Fact]
public void Injected_factory_wins_over_the_option()
@@ -1,6 +1,8 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
@@ -380,6 +382,71 @@ public sealed class NativeAbLegacyTagRuntimeTests
h.Channel.SentCount.ShouldBe(0);
}
// These two tests call the driver's WriteAsync directly to pin its OWN status mapping; resilience wrapping
// (CapabilityInvoker) is out of scope and would retry/mask the very status under test.
#pragma warning disable OTOPCUA0001
private sealed class HarnessFactory(Harness h) : IAbLegacyTagFactory
{
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) =>
new NativeAbLegacyTagRuntime(p, h.Scheduler, Vendor, Serial);
}
/// <summary>
/// Pins the CLIENT-VISIBLE status of the native status-bit write refusal, through the driver (Task 22
/// review r1 I2 — the release note states it). The runtime throws AbTransportException(Unsupported);
/// the driver's write path does not special-case it, so the client sees the generic transport
/// status, and nothing reaches the wire.
/// </summary>
[Theory]
[InlineData("T4:0.DN")]
[InlineData("C5:0.CU")]
[InlineData("R6:0.EN")]
public async Task A_status_bit_write_through_the_driver_reports_the_documented_status(string address)
{
await using var h = new Harness("slc500", "N7:0").Replies(Words(0));
const string host = "ab://10.0.0.1:44818/1,0";
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Bit", host, address, AbLegacyDataType.Bit)]),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-sb", new HarnessFactory(h));
await driver.InitializeAsync("{}", CancellationToken.None);
var results = await driver.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
// BadCommunicationError, NOT BadNotSupported: the driver's catch-all maps any runtime exception to
// it (same as the PLC-5 write refusal, #571), evicts the handle and marks health Degraded.
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("status-bit sub-element");
// Refused before the wire: the lazy runtime's init read may go out, but never a write (fnc 0xAA).
lock (h.Channel.Sent) h.Channel.Sent.ShouldNotContain(r => FncOf(r) == 0xAA);
await driver.ShutdownAsync(CancellationToken.None);
}
/// <summary>Positive control for the "no 0xAA on the wire" assertion above: the same driver path for a WORD
/// sub-element does send a typed write, so the detector can see one.</summary>
[Fact]
public async Task A_word_sub_element_write_through_the_driver_sends_a_typed_write()
{
await using var h = new Harness("slc500", "N7:0").Replies(Words(0));
const string host = "ab://10.0.0.1:44818/1,0";
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Pre", host, "T4:0.PRE", AbLegacyDataType.Int)]),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-sb", new HarnessFactory(h));
await driver.InitializeAsync("{}", CancellationToken.None);
var results = await driver.WriteAsync([new WriteRequest("Pre", (short)5)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
lock (h.Channel.Sent) h.Channel.Sent.ShouldContain(r => FncOf(r) == 0xAA);
await driver.ShutdownAsync(CancellationToken.None);
}
#pragma warning restore OTOPCUA0001
[Theory]
[InlineData("T4:0.PRE")]
[InlineData("T4:0.ACC")]